Most Clean Architecture articles stop at the circle diagram. The part that actually decides whether a team can live with the pattern is more mundane: which project does this class go in, which direction do the references point, and where do the interfaces live. I built the Contact Management Application as a .NET Core Web API to answer exactly those questions with running code, and this series walks through it layer by layer.
This first post is the map. It covers why the layering is worth the ceremony, what each layer owns, and how the solution is laid out on disk.
The short version#
- Clean Architecture splits the app into four layers with dependencies pointing inward; the domain never references infrastructure.
- The Dependency Inversion Principle does the real work: inner layers define interfaces, outer layers implement them.
- The sample solution has five projects:
Contact.Api,Contact.Application,Contact.Domain,Contact.Infrastructure, andContact.Common. - Data access uses Dapper behind repository interfaces; the rest of the series covers AutoMapper, FluentValidation, RBAC, logging, and Docker.

Clean Architecture
1. Why use Clean Architecture?#
The honest answer: because tight coupling is what kills codebases, and this layering makes coupling a compile error instead of a code-review argument. The benefits, stated once so I don’t have to repeat them in every section:
- Maintainability: each layer can be modified independently; a UI or database change doesn’t touch core logic.
- Testability: business logic decoupled from external dependencies is plain to unit test.
- Scalability: clear boundaries let you swap a database or add features without a rewrite.
- Framework independence: the core logic doesn’t care whether it’s hosted in ASP.NET Core or something else.
The cost is more projects, more interfaces, and more mapping code. For a small CRUD app that trade can go against you; for anything that will be maintained by more than one person for more than one year, I take the trade.
2. What is Clean Architecture?#
Clean Architecture divides an application into concentric layers, each with distinct responsibilities, and each layer only depends on the one directly inside it. Business logic sits at the center. The typical layers are:
Domain Layer (Core Business Logic): business rules and core entities.
Application Layer (Use Cases): orchestrates workflows and application logic, handling the interaction between the domain and external systems.
Infrastructure Layer (External Systems): manages integrations with databases, file storage, or messaging systems.
Presentation Layer (UI/Endpoints): interfaces directly with the user or API consumers.
This keeps your core business rules isolated, so you can update external systems without disrupting internal workflows.
The Dependency Inversion Principle (DIP) in Clean Architecture#
DIP is what makes the layering hold. It states that:
High-level modules (e.g., Application Layer) should not depend on low-level modules (e.g., Infrastructure Layer). Both should depend on abstractions.
Abstractions should not depend on details. The details (e.g., Infrastructure implementations) depend on abstractions (e.g., interfaces).
In this codebase that plays out concretely: the Domain and Application Layers define interfaces like IGenericRepository<T>, and the Infrastructure Layer supplies the Dapper-backed implementations. Dependencies flow toward the center, so the Domain Layer never notices when an external system changes.
3. Project structure (Clean Architecture in the Contact Management Application)#
The solution is organized into five projects, each owning one responsibility:
src
├── Contact.Api // API Layer (RESTful APIs)
├── Contact.Application // Application Layer (business use cases, services)
├── Contact.Domain // Domain Layer (business logic, entities)
├── Contact.Infrastructure // Infrastructure Layer (persistence, external systems)
└── Contact.Common // Shared utilities3.1 Domain Layer (Core Business Logic)#
Purpose: the core of the application: business logic, rules, and entities that define what the application is actually for.
Contents: business entities, value objects, and domain services that enforce rules, validations, and calculations.
Independence: isolated from all other layers and external dependencies, so it stays stable when databases or APIs are updated or replaced.
3.2 Application Layer (Use Cases)#
Purpose: the intermediary between the Domain Layer and external systems; defines workflows and coordinates Domain Layer components to fulfill specific use cases.
Contents: application services, use cases, and data transfer objects (DTOs) that organize the flow of data between scenarios.
Role with Domain Layer: interacts with domain entities and services but does not alter core business logic. If a business rule changes, that change lands in the Domain Layer, not here.
3.3 Infrastructure Layer (External Systems)#
Purpose: manages interactions with external systems: databases, file storage, logging, and third-party services.
Contents: repositories, logging mechanisms, data access implementations (e.g., Entity Framework or Dapper), and third-party integrations.
Interaction with Application Layer: fulfills interfaces and abstractions defined by the Domain and Application Layers. It contains no business logic; it stores, retrieves, and processes data on behalf of the inner layers.
3.4 Presentation Layer (UI/Endpoints)#
Purpose: the system’s entry point, such as APIs in a backend service or user interfaces in a frontend. It gathers input from users or external systems and directs it to the Application Layer.
Contents: controllers, view models, UI components, and other elements that handle interactions with users or client systems.
Interaction with Application Layer: communicates exclusively with the Application Layer, which coordinates with the Domain and Infrastructure Layers to fulfill requests.
4. Layer breakdown#
4.1. API Layer (Contact.Api)#
The API Layer is the entry point for the system, handling incoming HTTP requests. It maps the requests to services in the Application Layer and returns appropriate responses. In this layer, DTOs (Data Transfer Objects) abstract the underlying domain entities, keeping the client and the core domain separate.
Example: ContactPersonController
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ContactPersonController : ControllerBase
{
private readonly IContactPersonService _contactPersonService;
public ContactPersonController(IContactPersonService contactPersonService)
{
_contactPersonService = contactPersonService;
}
[HttpPost]
[ActivityLog("Creating new Contact")]
[AuthorizePermission("Contacts.Create")]
public async Task<IActionResult> Add(CreateContactPerson createContactPerson)
{
var createdContactPerson = await _contactPersonService.Add(createContactPerson);
return CreatedAtAction(nameof(GetById), new { id = createdContactPerson.Id }, createdContactPerson);
}
[HttpGet("{id}")]
[ActivityLog("Reading Contact By id")]
[AuthorizePermission("Contacts.Read")]
public async Task<IActionResult> GetById(Guid id)
{
var contactPerson = await _contactPersonService.FindByID(id);
if (contactPerson == null) return NotFound();
return Ok(contactPerson);
}
// Other actions...
}This controller uses dependency injection to communicate with the Application Layer through the IContactPersonService interface. It also maps incoming requests to DTOs, which are transformed to domain models inside the Application Layer.
Responsibilities of API Layer
Handling HTTP requests and responses.
Applying validation and formatting errors.
Delegating business logic to the Application Layer.
Applying custom attributes for authorization and activity logging (e.g.,
AuthorizePermissionandActivityLog).
4.2. Application Layer (Contact.Application)#
The Application Layer handles the core business logic and coordinates workflows. It acts as a middle layer between the API and Domain Layers. This layer uses AutoMapper to map DTOs to domain entities and back, keeping input/output data separate from the domain model.
Example: ContactPersonService
using AutoMapper;
using Contact.Application.Interfaces;
using Contact.Application.UseCases.ContactPerson;
using Contact.Domain.Entities;
using Contact.Domain.Interfaces;
namespace Contact.Application.Services;
public class ContactPersonService : GenericService<ContactPerson, ContactPersonResponse, CreateContactPerson, UpdateContactPerson>, IContactPersonService
{
public ContactPersonService(IGenericRepository<ContactPerson> repository, IMapper mapper, IUnitOfWork unitOfWork)
: base(repository, mapper, unitOfWork)
{
}
// Additional methods specific to ToDo can go here if needed
}using AutoMapper;
using Contact.Application.Interfaces;
using Contact.Domain.Entities;
using Contact.Domain.Interfaces;
namespace Contact.Application.Services;
public class GenericService<TEntity, TResponse, TCreate, TUpdate>
: IGenericService<TEntity, TResponse, TCreate, TUpdate>
where TEntity : BaseEntity
where TResponse : class
where TCreate : class
where TUpdate : class
{
private readonly IGenericRepository<TEntity> _repository;
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
public GenericService(IGenericRepository<TEntity> repository, IMapper mapper, IUnitOfWork unitOfWork)
{
_repository = repository;
_mapper = mapper;
_unitOfWork = unitOfWork;
}
public async Task<TResponse> Add(TCreate createDto)
{
using var transaction = _unitOfWork.BeginTransaction();
try
{
var entity = _mapper.Map<TEntity>(createDto);
var createdEntity = await _repository.Add(entity, transaction);
await _unitOfWork.CommitAsync();
return _mapper.Map<TResponse>(createdEntity); // Map to clean response DTO
}
catch
{
await _unitOfWork.RollbackAsync();
throw;
}
}
public async Task<TResponse> FindByID(Guid id)
{
var entity = await _repository.FindByID(id);
return _mapper.Map<TResponse>(entity); // Map to clean response DTO
}
public async Task<IEnumerable<TResponse>> FindAll()
{
var entities = await _repository.FindAll();
return _mapper.Map<IEnumerable<TResponse>>(entities); // Map to clean response DTOs
}
}Responsibilities of the Application Layer
Implements business logic and rules.
Coordinates between controllers and repositories.
Contains the DTOs (Data Transfer Objects) for create, update, and response.
Uses AutoMapper to map DTOs to domain entities.
Runs validation through FluentValidation.
4.2.1 AutoMapper for DTO and Entity Mapping#
AutoMapper handles the conversion of DTOs to domain entities and back.
Example: AutoMapper Configuration
public class ContactPersonMappingProfile : Profile
{
public ContactPersonMappingProfile()
{
CreateMap<CreateContactPersonDto, ContactPerson>();
CreateMap<ContactPerson, ContactPersonResponse>();
}
}4.3. Domain Layer (Contact.Domain)#
The Domain Layer contains the core business logic and entities. It’s completely decoupled from external systems, and all business rules reside in this layer.
Here’s an example of the ContactPerson entity:
Example: ContactPerson Entity
public class ContactPerson : BaseEntity
{
public string FirstName { get; set; }
public string LastName { get; set; }
public long Mobile { get; set; }
public string Email { get; set; }
}This ContactPerson entity extends the BaseEntity class, which provides common properties like Id, CreatedOn, and UpdatedOn. These entities represent the core business data and are used in business logic across the application.
4.4. Infrastructure Layer (Contact.Infrastructure)#
The Infrastructure Layer implements external system integrations, like databases or logging services. This layer interacts with the Application Layer via interfaces, keeping the coupling loose.
Example: ContactPersonRepository
public class ContactPersonRepository : IContactPersonRepository
{
private readonly IDapperHelper _dapperHelper;
public ContactPersonRepository(IDapperHelper dapperHelper)
{
_dapperHelper = dapperHelper;
}
public async Task<ContactPerson> AddAsync(ContactPerson contact)
{
var sql = @"INSERT INTO Contacts (FirstName, LastName, Mobile, Email, CreatedOn)
VALUES (@FirstName, @LastName, @Mobile, @Email, @CreatedOn)";
await _dapperHelper.ExecuteAsync(sql, contact);
return contact;
}
}This repository uses Dapper rather than Entity Framework. That was a deliberate choice for this project: the SQL stays visible and the repository interfaces keep it swappable, which the Dapper article covers in detail.
The full series#
This article is the starting point for a series built around the Contact Management Application. Each post takes one concern and shows the working implementation:
Clean Architecture: Introduction to the Project Structure (You are here)
Clean Architecture: Implementing AutoMapper for DTO Mapping and Audit Logging
Clean Architecture: Dependency Injection Setup Across Layers
Clean Architecture: Handling Authorization and Role-Based Access Control (RBAC)
Clean Architecture: Implementing Activity Logging with Custom Attributes
Clean Architecture: Unit of Work Pattern and Its Role in Managing Transactions
Clean Architecture: Using Dapper for Data Access and Repository Pattern
Clean Architecture: Best Practices for Creating and Using DTOs in the API
Clean Architecture: Error Handling and Exception Management in the API
Clean Architecture: Dockerizing the .NET Core API, Angular and MS SQL Server
Clean Architecture: Seeding Initial Data Using Docker Compose and SQL Scripts
Key takeaways#
- Dependencies point inward, always. If a Domain project ever references Infrastructure, the architecture is already gone; the layering only works when the compiler enforces it.
- Interfaces live with the layer that needs them, implementations live outside. That single rule is most of the Dependency Inversion Principle in practice.
- The Application Layer is the traffic controller: DTOs in, domain entities inside, DTOs out. Controllers stay thin because of it.
- Pay the layering tax only where it earns rent. This structure exists for codebases with a long life and multiple maintainers, not for a weekend CRUD app.
Clone the Contact Management Application, run it, and trace one request from ContactPersonController down to the Dapper repository. Then continue with the AutoMapper article, which picks up where the GenericService above leaves off.
