DEV_NET_CORE
GET_STARTED
Design & ArchitectureClean Architecture and modular boundaries

Layered architecture vs Clean Architecture vs ports-and-adapters

Overview

Layered Architecture, Clean Architecture, and Ports-and-Adapters are related ways to organize software around responsibilities and boundaries. All three try to reduce coupling and make change safer, but they differ in how they define boundaries and, most importantly, in the direction of compile-time dependencies.

Traditional Layered Architecture commonly separates an application into presentation, business logic, and data access layers:

Code
Presentation
    |
Business Logic
    |
Data Access
    |
Database

This structure is familiar and works well for many business applications. Its main weakness is that business logic often depends directly on data access or framework details.

Clean Architecture places business rules and application use cases at the center. User interfaces, databases, messaging systems, external APIs, and frameworks are treated as replaceable details around that center. Compile-time dependencies point inward:

Code
Frameworks and Infrastructure
              |
          Adapters
              |
       Application Use Cases
              |
            Domain

Ports-and-Adapters, also called Hexagonal Architecture, describes the same broad goal from an interaction perspective. The application exposes or consumes ports, which are technology-independent contracts. Adapters connect those ports to HTTP, databases, message brokers, command-line applications, tests, and external services.

These approaches are used in ASP.NET Core APIs, modular monoliths, desktop applications, background workers, microservices, and systems with substantial business rules or multiple external integrations.

The topic matters in interviews because candidates are expected to do more than draw layers. A strong answer explains:

  • The difference between a layer and a deployment tier.
  • How compile-time dependencies differ from runtime call flow.
  • Where domain entities and use cases belong.
  • What ports and adapters are.
  • How ASP.NET Core dependency injection wires the design together.
  • What each architecture improves.
  • What complexity each architecture introduces.
  • When a simpler design is more appropriate.

The practical goal is not architectural purity. It is to keep business behavior understandable and protect it from details that change for unrelated reasons.

Core Concepts

Architecture, Structure, and Dependency Direction

Software architecture describes the high-level organization of a system, its major boundaries, and the rules governing how parts interact.

Three different views are important:

  • Code organization: Projects, folders, modules, namespaces, and packages.
  • Compile-time dependencies: Which project or type references another.
  • Runtime interactions: Which object calls another while the application runs.

These views are related but not identical.

For example, an application service can call a repository implementation at runtime while having no compile-time dependency on that implementation:

Code
Compile time:
Infrastructure -> Application

Runtime:
Application service -> Repository implementation

This is possible because the application service depends on an interface owned by the application, and infrastructure implements it.

Understanding this distinction is essential for Clean Architecture and Ports-and-Adapters.

Logical Layers vs Physical Tiers

A layer is a logical separation inside the codebase. A tier is a physical deployment or process boundary.

An application can have several layers while being deployed as one process:

Code
One ASP.NET Core deployment
  - API layer
  - Application layer
  - Domain layer
  - Infrastructure layer

It can also distribute tiers across processes or machines:

Code
Browser tier -> API tier -> Database tier

Calling an architecture "three-tier" does not necessarily describe its internal code dependencies. Likewise, a four-project Clean Architecture solution can still be a monolith deployed as one unit.

Common interview mistake:

Code
Layer = project
Tier = server

That statement is a useful approximation, but the deeper distinction is logical responsibility versus physical deployment.

Traditional Layered Architecture

Traditional Layered Architecture organizes code by technical responsibility.

Typical layers include:

  • Presentation layer: Controllers, endpoints, UI models, and serialization.
  • Business logic layer: Services, workflows, and business rules.
  • Data access layer: Database queries, ORM code, and repositories.
  • Database: Persistent storage.

Typical dependency flow:

Code
Presentation -> Business Logic -> Data Access

Typical runtime flow follows the same direction:

Code
HTTP request
  -> Controller
  -> Business service
  -> Data access service
  -> Database

Closed and Open Layers

In a closed-layer design, a layer can call only the layer immediately below it:

Code
Presentation -> Business -> Data Access

The presentation layer cannot call data access directly.

In an open-layer design, a layer can skip lower layers:

Code
Presentation ---------> Data Access
             \-> Business

Closed layers enforce boundaries more strongly but can create pass-through methods. Open layers can reduce ceremony for simple operations but make dependencies harder to control.

Benefits of Layered Architecture

  • Familiar to many developers.
  • Easy to explain and start.
  • Clear separation of technical concerns.
  • Suitable for straightforward CRUD applications.
  • Often maps naturally to existing enterprise systems.
  • Can remain one simple deployment.
  • Supports gradual migration from older applications.

Limitations of Traditional Layering

The most important limitation is downward dependency:

Code
Business Logic -> Data Access implementation

This can cause:

  • Business rules coupled to EF Core, SQL, or a specific storage model.
  • Unit tests that require database infrastructure.
  • Persistence concerns leaking into business behavior.
  • Changes to lower layers affecting higher layers.
  • An anemic business layer that only forwards CRUD calls.
  • Features spread horizontally across several technical folders.

Layering itself is not the problem. The issue is allowing important policies to depend directly on volatile details.

Clean Architecture

Clean Architecture organizes the application around business rules and use cases. The exact project names vary, but a common model includes:

  • Domain: Enterprise or business rules.
  • Application: Use cases and application-specific orchestration.
  • Adapters: Translation between external models and application contracts.
  • Infrastructure and frameworks: Databases, HTTP frameworks, messaging, file systems, and vendors.

The central rule is:

Code
Source-code dependencies point inward.

Outer layers may depend on inner layers. Inner layers must not depend on outer layers.

Code
API ------------\
Infrastructure ---> Application ---> Domain
Worker ---------/

The domain does not reference ASP.NET Core, EF Core, message brokers, or vendor SDKs. Application use cases can depend on domain types and on abstractions required to perform external work.

Domain Layer

The domain contains business concepts and rules:

  • Entities.
  • Value objects.
  • Aggregates.
  • Domain services.
  • Domain events.
  • Invariants.
  • Domain-specific exceptions.

Example:

Code
public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    public OrderId Id { get; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines;

    public void Confirm()
    {
        if (_lines.Count == 0)
        {
            throw new DomainException(
                "An order must contain at least one line.");
        }

        if (Status != OrderStatus.Draft)
        {
            throw new DomainException(
                "Only draft orders can be confirmed.");
        }

        Status = OrderStatus.Confirmed;
    }
}

This rule does not need to know whether the order is stored with EF Core, MongoDB, or an external service.

Application Layer

The application layer coordinates use cases:

  • Receives an application request.
  • Loads required domain objects.
  • Invokes domain behavior.
  • Coordinates external operations through interfaces.
  • Commits results.
  • Returns an application result.
Code
public sealed class ConfirmOrderHandler(
    IOrderRepository orders,
    IUnitOfWork unitOfWork)
{
    public async Task HandleAsync(
        ConfirmOrder command,
        CancellationToken cancellationToken)
    {
        Order order = await orders.GetAsync(
            command.OrderId,
            cancellationToken)
            ?? throw new OrderNotFoundException(command.OrderId);

        order.Confirm();

        await unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

The handler defines what the use case needs. It does not know how the repository reaches the database.

Infrastructure Layer

Infrastructure implements external details:

  • EF Core DbContext.
  • Repository implementations.
  • Email or SMS gateways.
  • File storage.
  • Message publishers.
  • HTTP clients.
  • Clock, identity, and configuration adapters.
Code
public sealed class EfOrderRepository(AppDbContext db)
    : IOrderRepository
{
    public Task<Order?> GetAsync(
        OrderId id,
        CancellationToken cancellationToken)
    {
        return db.Orders
            .Include(order => order.Lines)
            .SingleOrDefaultAsync(
                order => order.Id == id,
                cancellationToken);
    }
}

Infrastructure references the application or domain project because it implements their contracts.

Presentation Layer

The presentation layer translates transport details into application requests:

Code
app.MapPost(
    "/orders/{orderId:guid}/confirmation",
    async (
        Guid orderId,
        ConfirmOrderHandler handler,
        CancellationToken cancellationToken) =>
    {
        await handler.HandleAsync(
            new ConfirmOrder(new OrderId(orderId)),
            cancellationToken);

        return Results.NoContent();
    });

HTTP status codes, route values, JSON, and authentication metadata remain at the boundary rather than entering the domain.

Ports-and-Adapters Architecture

Ports-and-Adapters views the application as a core surrounded by external actors and technologies.

Code
           HTTP Adapter
                |
            Input Port
                |
Database <-- Application Core --> Payment Provider
Adapter       |             |        Adapter
          Output Port   Output Port

A port is a technology-independent interaction point. An adapter translates between a specific technology and a port.

The architecture is called hexagonal not because systems need six sides, but because the shape allows multiple interchangeable connections around the application.

Primary and Secondary Actors

Ports-and-Adapters commonly distinguishes actors by their relationship to the application.

  • Primary or driving actor: Initiates an interaction with the application.
  • Secondary or driven actor: Is called by the application to complete work.

Primary actors:

  • HTTP clients.
  • Command-line users.
  • Scheduled jobs.
  • Message consumers.
  • Automated tests.

Secondary actors:

  • Databases.
  • Message brokers.
  • Payment providers.
  • File storage.
  • Email services.
  • External APIs.

The terms describe interaction direction, not importance.

Input Ports and Output Ports

An input port describes a use case the application offers.

Code
public interface IConfirmOrderUseCase
{
    Task ExecuteAsync(
        ConfirmOrder command,
        CancellationToken cancellationToken);
}

An HTTP endpoint and a message consumer can both be input adapters:

Code
HTTP endpoint -----\
                    -> IConfirmOrderUseCase
Message consumer --/

An output port describes something the application needs from the outside:

Code
public interface IOrderRepository
{
    Task<Order?> GetAsync(
        OrderId id,
        CancellationToken cancellationToken);
}

EF Core, an in-memory test implementation, or a remote API can provide output adapters.

Some teams call these ports inbound/outbound, driving/driven, or primary/secondary. The terminology varies, but the boundary principle is the same.

Adapters Translate, Not Just Forward

A meaningful adapter protects one model from another.

An HTTP adapter translates:

  • Route and query values.
  • JSON request models.
  • Authentication context.
  • Validation failures.
  • Application results.
  • HTTP status codes.

A database adapter translates:

  • Domain identifiers.
  • Persistence models.
  • Queries.
  • Transactions.
  • Concurrency failures.

An external API adapter translates:

  • Vendor request and response formats.
  • Authentication.
  • Error codes.
  • Retries and timeouts.
  • Vendor-specific identifiers.

An adapter that only mirrors another API may be unnecessary unless it establishes a boundary that is expected to matter.

Clean Architecture vs Ports-and-Adapters

The two approaches strongly overlap.

Both:

  • Keep business behavior independent of external technology.
  • Use dependency inversion at boundaries.
  • Treat databases and frameworks as details.
  • Support multiple adapters.
  • Encourage testing through stable application contracts.

Different emphasis:

Clean ArchitecturePorts-and-Adapters
Emphasizes concentric policy layersEmphasizes application ports and external adapters
Often distinguishes Domain and ApplicationOften discusses one application core
Uses the Dependency RuleUses driving and driven interactions
Frequently shown as circlesFrequently shown as a hexagon
Focuses on policy levelFocuses on boundary interaction

In practice, a solution can be described accurately by both names.

Clean Architecture vs Traditional Layered Architecture

The decisive difference is dependency direction.

Traditional:

Code
Presentation -> Business -> Data Access

Clean:

Code
Presentation ----\
                  -> Application -> Domain
Infrastructure --/

Traditional layering can still be well designed. It can use interfaces, encapsulate data access, and maintain strong boundaries. Clean Architecture applies dependency inversion more systematically so that important business policy does not depend on external details.

Runtime Flow vs Compile-Time Dependency

This is a frequent interview topic.

At runtime:

Code
Endpoint
  -> Application handler
  -> Repository interface
  -> EF repository
  -> Database

At compile time:

Code
API -> Application
Infrastructure -> Application
Application -> Domain

The application calls infrastructure behavior through an abstraction, but infrastructure owns the implementation and references the contract.

The Composition Root

The composition root is the location where concrete implementations are connected to abstractions.

In ASP.NET Core, it is usually Program.cs or an extension called from it:

Code
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IUnitOfWork>(
    serviceProvider =>
        serviceProvider.GetRequiredService<AppDbContext>());
builder.Services.AddScoped<IConfirmOrderUseCase, ConfirmOrderHandler>();

The entry-point project may reference infrastructure for registration. This is acceptable when concrete types remain confined to composition code.

Code
Compile-time exception:
API references Infrastructure only at the composition root.

Runtime result:
The DI container supplies infrastructure implementations
to application-owned interfaces.

Do not hide the composition root behind service location throughout the application. Dependencies should remain explicit in constructors or method parameters.

Example .NET Solution Structures

Traditional Layered Solution

Code
Shop.Api
  -> Shop.Business
      -> Shop.Data

Shop.Data
  -> EF Core
  -> SQL Server

Possible folders:

Code
Shop.Api/
  Controllers/

Shop.Business/
  Services/
  Models/

Shop.Data/
  AppDbContext.cs
  Repositories/

This is simple and may be sufficient for a CRUD-focused application.

Clean Architecture Solution

Code
Shop.Domain

Shop.Application
  -> Shop.Domain

Shop.Infrastructure
  -> Shop.Application
  -> Shop.Domain

Shop.Api
  -> Shop.Application
  -> Shop.Infrastructure only for composition

Possible folders:

Code
Shop.Domain/
  Orders/
    Order.cs
    OrderLine.cs
    OrderId.cs

Shop.Application/
  Orders/
    ConfirmOrder/
      ConfirmOrder.cs
      ConfirmOrderHandler.cs
  Abstractions/
    IOrderRepository.cs
    IUnitOfWork.cs

Shop.Infrastructure/
  Persistence/
    AppDbContext.cs
    EfOrderRepository.cs
  Messaging/
  Payments/

Shop.Api/
  Endpoints/
  Contracts/
  Program.cs

Feature-Oriented Variation

Clean Architecture does not require organizing every project by technical type.

Code
Shop.Application/
  Orders/
    CreateOrder/
    ConfirmOrder/
    CancelOrder/
  Customers/
    RegisterCustomer/

Feature-oriented folders often improve discoverability while project references still enforce architectural dependencies.

Testing Across the Boundaries

Different layers require different tests.

Domain Tests

Test business invariants without infrastructure:

Code
[Fact]
public void Confirm_rejects_an_empty_order()
{
    var order = Order.CreateDraft();

    Action confirm = order.Confirm;

    confirm.Should().Throw<DomainException>();
}

Application Tests

Use fakes for owned ports where isolation is useful:

Code
var repository = new InMemoryOrderRepository(existingOrder);
var unitOfWork = new SpyUnitOfWork();
var handler = new ConfirmOrderHandler(repository, unitOfWork);

await handler.HandleAsync(command, CancellationToken.None);

unitOfWork.SaveCount.Should().Be(1);

Adapter Integration Tests

Test infrastructure against the real technology or a realistic substitute:

  • EF Core repository against SQL Server, PostgreSQL, or SQLite as appropriate.
  • HTTP adapter through WebApplicationFactory.
  • Message adapter against a broker container or test environment.
  • Vendor adapter against a sandbox or contract test.

Clean Architecture does not eliminate integration testing. It makes test responsibilities clearer.

Data Models Across Boundaries

One model should not automatically be reused everywhere.

Potential model types:

  • HTTP request and response contracts.
  • Application commands and results.
  • Domain entities and value objects.
  • Persistence entities or EF Core configurations.
  • External vendor DTOs.

Separate models are useful when they protect different contracts or change for different reasons. They become unnecessary ceremony when every field is mapped identically through many layers with no boundary benefit.

Example:

Code
public sealed record CreateOrderRequest(
    Guid CustomerId,
    IReadOnlyList<CreateOrderLineRequest> Lines);

public sealed record CreateOrder(
    CustomerId CustomerId,
    IReadOnlyList<NewOrderLine> Lines);

The HTTP adapter validates and translates transport concerns before invoking the use case.

Transactions and Unit-of-Work Boundaries

Use cases often form transaction boundaries:

Code
Load aggregate
Apply business behavior
Persist changes
Publish required integration work safely

The domain should not start database transactions. Transaction management belongs to application or infrastructure coordination.

For a simple EF Core application, DbContext.SaveChangesAsync may be enough. A custom unit-of-work abstraction is justified only if it creates a useful application boundary.

Domain Events and Integration Events

A domain event expresses something that happened inside the domain:

Code
public sealed record OrderConfirmed(OrderId OrderId) : IDomainEvent;

An integration event is an external contract published to other modules or services:

Code
public sealed record OrderConfirmedV1(
    Guid OrderId,
    DateTimeOffset ConfirmedAtUtc);

They should not automatically be the same type. An adapter or application service can translate internal events into stable external contracts.

Publishing reliably may require an outbox pattern. This is infrastructure complexity that should be introduced when reliable cross-process delivery is actually required.

Common Mistakes

Treating Project Names as Architecture

Creating projects named Domain, Application, and Infrastructure does not create Clean Architecture if:

  • The domain references EF Core.
  • Application handlers use DbContext directly despite a claimed boundary.
  • Controllers contain business rules.
  • Infrastructure models leak into API contracts.
  • All projects reference one another.

Architecture is enforced by dependency and responsibility rules, not labels.

Creating an Interface for Every Class

Dependency inversion applies at important boundaries. It does not require one interface per implementation.

Good candidates:

  • Database access required by a use case.
  • External payment or messaging services.
  • Time, identity, or storage when they affect business behavior.

Weak candidates:

  • Stateless internal classes with no boundary value.
  • DTO mappers created only to satisfy a layering convention.
  • Interfaces whose only consumer and implementation always change together.

Allowing the Domain to Know Transport or Persistence Details

Examples:

  • Domain methods returning IActionResult.
  • Domain entities decorated with API serialization behavior.
  • Business rules based on HTTP status codes.
  • Domain services accepting EF Core queries.
  • Domain exceptions named after database errors.

Translate these concerns at adapters.

Excessive Mapping

Mapping is a cost. Use separate models where contracts differ. Avoid a mandatory model per layer when the objects have no independent meaning.

Anemic Domain with Ceremony

A system can have many Clean Architecture projects while all business behavior remains in application handlers and entities contain only getters and setters.

Place invariants near the state they protect. Use application services for orchestration, not as a replacement for all domain behavior.

Pass-Through Use Cases

Not every endpoint needs a command, handler, service, repository, specification, mapper, and response factory. Simple queries can use a straightforward path while respecting important boundaries.

Depending on the DI Container

Application and domain code should not call IServiceProvider to find dependencies. Service location hides requirements and couples code to the container.

Use constructor or method injection.

Assuming the Database Is Easily Replaceable

Clean boundaries reduce coupling, but replacing a relational database with a document database is rarely a simple adapter swap. Query behavior, transactions, consistency, indexing, and data modeling differ.

The architecture protects business policy, but it does not erase technology semantics.

Choosing the Appropriate Style

Use a simple layered design when:

  • The application is mostly CRUD.
  • Business rules are limited.
  • One team owns the system.
  • Technology choices are stable.
  • Fast delivery and low ceremony matter most.

Use Clean Architecture or Ports-and-Adapters when:

  • Business rules are substantial and long-lived.
  • External systems change independently.
  • Multiple interfaces drive the same use cases.
  • Infrastructure needs focused integration testing.
  • The application must remain testable without external resources.
  • Clear module boundaries matter.

Do not assume a large number of projects is required. A small application can enforce the same dependency principles with folders and internal types.

Best Practices

  • Start with business capabilities and use cases.
  • Keep domain behavior independent from transport and infrastructure.
  • Make dependencies explicit.
  • Define ports from the application's needs.
  • Keep interfaces narrow and semantic.
  • Place adapters at actual technology boundaries.
  • Keep the composition root at the entry point.
  • Enforce project reference rules.
  • Test domain policy separately from adapter integration.
  • Prefer feature-oriented organization inside layers.
  • Introduce mappings only where contracts genuinely differ.
  • Choose the simplest architecture that satisfies current quality requirements.
  • Reassess boundaries as the system and team evolve.

Comparison Summary

ConcernTraditional LayeredClean ArchitecturePorts-and-Adapters
Main organizing ideaTechnical responsibilityPolicy level and inward dependenciesApplication ports and technology adapters
Typical dependency directionTop to bottomOutside to insideAdapters to application ports
Business dependency on data accessCommonAvoidedAvoided
Database roleBottom layerOuter detailDriven adapter
HTTP roleTop layerOuter detailDriving adapter
Testing core logicCan require lower layersCore can be isolatedCore can be driven through ports
Main strengthFamiliar simplicityProtected business policyExplicit interaction boundaries
Main riskBusiness coupled to detailsExcessive layers and mappingToo many ports and abstractions
Good fitStraightforward business appsNon-trivial domain applicationsMultiple interfaces and integrations

Interview Practice

PreviousDependency inversion and inward-facing dependenciesNext UpModular monolith structure and feature-based organization