Skip to content

Refactor solution to adopt a Clean Architecture-inspired structure #266

@nanotaboada

Description

@nanotaboada

Description

The current folder structure follows a traditional layered architecture (Controllers → Services → Repositories → Data) which, while functional, has some architectural limitations:

src/Dotnet.Samples.AspNetCore.WebApi/
├── Controllers/                                # HTTP/API concerns
├── Services/                                   # Business logic + orchestration
├── Repositories/                               # Data access abstractions & implementations
├── Data/                                       # EF Core DbContext
├── Models/                                     # Mix of entities + DTOs ⚠️
├── Enums/                                      # Domain enums
├── Validators/                                 # Input validation
├── Mappings/                                   # Object mapping
├── Utilities/                                  # Various utilities
├── Configurations/                             # Infrastructure configuration
└── Extensions/                                 # Service registration

Current Architecture Issues

  • Domain entities mixed with DTOs in Models/ (e.g., Player.cs alongside PlayerRequestModel.cs)
  • Business logic scattered across multiple layers
  • Dependencies flow in all directions, making testing difficult
  • Tight coupling between infrastructure and business logic
  • Limited ability to swap implementations (database, external services)

This issue proposes migrating to a Clean Architecture-inspired folder structure to improve maintainability, testability, and adherence to SOLID principles.


Minimal vs Full Clean Architecture

Based on the Ardalis CleanArchitecture patterns, there are two valid approaches:

Aspect Minimal (Recommended) Full
Structure Single project with layer folders 4+ separate projects
Separation Convention-based (folders) Compiler-enforced (project references)
Complexity Lower Higher
Best For MVPs, learning projects, small teams Large enterprise apps, multiple teams
Migration Can grow to Full later N/A
Naming No redundancy issues Requires careful naming (avoid .WebApi.WebApi)

When to Use Each

Choose Minimal when:

  • Single bounded context (like our Player entity)
  • Small team or solo development
  • Learning-focused or PoC project
  • Want Clean Architecture benefits without solution complexity

Choose Full when:

  • Multiple bounded contexts or domains
  • Large teams needing strict boundaries
  • Need compile-time dependency enforcement
  • Planning microservices extraction

Proposed Solution (Minimal Approach)

Restructure the existing project using folder-based layers:

src/Dotnet.Samples.AspNetCore.WebApi/
├── Core/                                        # Domain Layer
│   ├── Entities/
│   │   └── Player.cs                            # Pure domain entity
│   └── Enums/
│       ├── Enumeration.cs
│       └── Position.cs
│
├── Application/                                 # Application Layer
│   ├── Contracts/
│   │   ├── DTOs/
│   │   │   ├── PlayerRequestDto.cs              # Input DTOs
│   │   │   └── PlayerResponseDto.cs             # Output DTOs
│   │   ├── Persistence/
│   │   │   ├── IPlayerRepository.cs             # Repository contracts
│   │   │   └── IRepository.cs                   # Generic contracts
│   │   └── Services/
│   │       └── IPlayerService.cs                # Service contracts
│   ├── Services/
│   │   └── PlayerService.cs                     # Business logic
│   ├── Validators/
│   │   └── PlayerRequestDtoValidator.cs         # Input validation
│   ├── Mappings/
│   │   └── PlayerMappingProfile.cs              # AutoMapper profiles
│   └── Exceptions/                              # Custom business exceptions
│
├── Infrastructure/                              # Infrastructure Layer
│   ├── Persistence/
│   │   ├── PlayerDbContext.cs                   # EF Core DbContext
│   │   ├── Repositories/
│   │   │   ├── Repository.cs                    # Generic implementation
│   │   │   └── PlayerRepository.cs              # Specific implementation
│   │   ├── Configurations/
│   │   │   └── PlayerConfiguration.cs           # EF entity configuration
│   │   └── Migrations/                          # EF Core migrations
│   └── Extensions/
│       └── InfrastructureServiceExtensions.cs   # Infrastructure DI setup
│
├── Api/                                         # Presentation Layer
│   ├── Controllers/
│   │   └── PlayerController.cs
│   ├── Configurations/
│   │   ├── RateLimiterConfiguration.cs
│   │   └── AuthorizeCheckOperationFilter.cs
│   ├── Extensions/
│   │   └── ApiServiceExtensions.cs              # API layer DI setup
│   ├── Middleware/                              # Custom middleware
│   └── Filters/                                 # Action filters
│
├── Utilities/                                   # Shared utilities
│   ├── HttpContextUtilities.cs
│   ├── PlayerData.cs
│   └── SwaggerUtilities.cs
│
└── Program.cs                                   # Application entry point

Namespace Convention

Namespaces should mirror the folder structure for clarity:

// Domain layer
namespace Dotnet.Samples.AspNetCore.WebApi.Core.Entities;
namespace Dotnet.Samples.AspNetCore.WebApi.Core.Enums;

// Application layer
namespace Dotnet.Samples.AspNetCore.WebApi.Application.Contracts.DTOs;
namespace Dotnet.Samples.AspNetCore.WebApi.Application.Contracts.Persistence;
namespace Dotnet.Samples.AspNetCore.WebApi.Application.Services;

// Infrastructure layer
namespace Dotnet.Samples.AspNetCore.WebApi.Infrastructure.Persistence;
namespace Dotnet.Samples.AspNetCore.WebApi.Infrastructure.Persistence.Repositories;

// API layer
namespace Dotnet.Samples.AspNetCore.WebApi.Api.Controllers;

Dependency Flow (Clean Architecture Principles)

Api → Application → Core
Infrastructure → Application → Core

Even without separate projects, maintain this flow by convention:

  • Core/ has zero dependencies on other layers
  • Application/ references only Core/
  • Infrastructure/ implements interfaces from Application/
  • Api/ orchestrates via dependency injection

Benefits of This Approach

1. Clear Separation of Concerns

  • Core: Pure business entities and rules (no infrastructure concerns)
  • Application: Use cases, business logic, and contracts
  • Infrastructure: Technical implementations (database, caching, external APIs)
  • Api: HTTP/presentation layer

2. Improved Testability

  • Domain logic can be tested in complete isolation
  • Application services can be tested with mocked repositories
  • No need to mock database for business logic tests

3. Dependency Inversion

  • Application defines IPlayerRepository interface
  • Infrastructure provides PlayerRepository implementation
  • Easy to swap SQLite for PostgreSQL, or add caching, without touching business logic

4. Explicit Boundaries

  • DTOs separated from domain entities
  • Clear API contracts via interfaces
  • Infrastructure details hidden from business logic

5. Simpler Structure

  • No solution restructuring needed
  • No naming redundancy issues (avoids .WebApi.WebApi)
  • Easy to navigate — single project, clear folder hierarchy
  • Flexible — can upgrade to Full approach later if needed

Migration Strategy

All commands assume you're in the repository root directory.

Phase 1: Create Folder Structure

# Create Core (Domain) layer folders
mkdir -p src/Dotnet.Samples.AspNetCore.WebApi/Core/{Entities,Enums}

# Create Application layer folders
mkdir -p src/Dotnet.Samples.AspNetCore.WebApi/Application/{Contracts/{DTOs,Persistence,Services},Services,Validators,Mappings,Exceptions}

# Create Infrastructure layer folders
mkdir -p src/Dotnet.Samples.AspNetCore.WebApi/Infrastructure/{Persistence/{Repositories,Configurations,Migrations},Extensions}

# Create Api (Presentation) layer folders
mkdir -p src/Dotnet.Samples.AspNetCore.WebApi/Api/{Controllers,Configurations,Extensions,Middleware,Filters}

Phase 2: Move Domain Layer (Core/)

# Move domain entity (keep it pure, remove any infrastructure concerns)
mv src/Dotnet.Samples.AspNetCore.WebApi/Models/Player.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Core/Entities/Player.cs

# Move enums to Core
mv src/Dotnet.Samples.AspNetCore.WebApi/Enums/Position.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Core/Enums/Position.cs

mv src/Dotnet.Samples.AspNetCore.WebApi/Enums/Enumeration.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Core/Enums/Enumeration.cs

After moving, update namespaces in each file to match the new folder structure.

Phase 3: Move Application Layer

# Move DTOs (rename Model → Dto)
mv src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerRequestModel.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Contracts/DTOs/PlayerRequestDto.cs

mv src/Dotnet.Samples.AspNetCore.WebApi/Models/PlayerResponseModel.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Contracts/DTOs/PlayerResponseDto.cs

# Move repository interfaces (contracts)
mv src/Dotnet.Samples.AspNetCore.WebApi/Repositories/IRepository.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Contracts/Persistence/IRepository.cs

mv src/Dotnet.Samples.AspNetCore.WebApi/Repositories/IPlayerRepository.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Contracts/Persistence/IPlayerRepository.cs

# Move service interface (contract)
mv src/Dotnet.Samples.AspNetCore.WebApi/Services/IPlayerService.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Contracts/Services/IPlayerService.cs

# Move service implementation
mv src/Dotnet.Samples.AspNetCore.WebApi/Services/PlayerService.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Services/PlayerService.cs

# Move validators (rename to match DTO naming)
mv src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Validators/PlayerRequestDtoValidator.cs

# Move mappings
mv src/Dotnet.Samples.AspNetCore.WebApi/Mappings/PlayerMappingProfile.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Application/Mappings/PlayerMappingProfile.cs

After moving, update namespaces and rename classes (PlayerRequestModelPlayerRequestDto, etc.).

Phase 4: Move Infrastructure & API Layers

Infrastructure:

# Move DbContext
mv src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Infrastructure/Persistence/PlayerDbContext.cs

# Move migrations folder
mv src/Dotnet.Samples.AspNetCore.WebApi/Migrations/* \
   src/Dotnet.Samples.AspNetCore.WebApi/Infrastructure/Persistence/Migrations/

# Move repository implementations
mv src/Dotnet.Samples.AspNetCore.WebApi/Repositories/Repository.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Infrastructure/Persistence/Repositories/Repository.cs

mv src/Dotnet.Samples.AspNetCore.WebApi/Repositories/PlayerRepository.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Infrastructure/Persistence/Repositories/PlayerRepository.cs

# Move infrastructure-related extensions
mv src/Dotnet.Samples.AspNetCore.WebApi/Extensions/ServiceCollectionExtensions.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Infrastructure/Extensions/InfrastructureServiceExtensions.cs

API:

# Move controllers
mv src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Api/Controllers/PlayerController.cs

# Move API configurations
mv src/Dotnet.Samples.AspNetCore.WebApi/Configurations/RateLimiterConfiguration.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Api/Configurations/RateLimiterConfiguration.cs

mv src/Dotnet.Samples.AspNetCore.WebApi/Configurations/AuthorizeCheckOperationFilter.cs \
   src/Dotnet.Samples.AspNetCore.WebApi/Api/Configurations/AuthorizeCheckOperationFilter.cs

Phase 5: Cleanup & Validation

# Remove empty old folders
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Models
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Enums
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Services
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Repositories
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Data
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Migrations
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Controllers
rmdir src/Dotnet.Samples.AspNetCore.WebApi/Configurations

# Verify build succeeds
dotnet build

# Run all tests
dotnet test

After cleanup, update all using statements across the codebase to reference the new namespaces.


Acceptance Criteria

  • Core folder contains only pure domain logic (entities, enums, value objects)
  • Application folder contains business logic and defines contracts (interfaces)
  • Infrastructure folder implements Application interfaces
  • Api folder contains only HTTP/presentation concerns
  • Namespaces mirror folder structure (e.g., *.Core.Entities, *.Application.Services)
  • DTOs separated from domain entities (no more mixed Models/ folder)
  • Dependency flow maintained by convention (Core ← Application ← Infrastructure/Api)
  • All existing tests pass after migration
  • Build succeeds without errors or warnings
  • Docker compose still functions correctly
  • Database migrations continue to work
  • API functionality remains unchanged (same endpoints, same behavior)
  • Documentation updated (README reflects new structure)

Trade-offs

Pros ✅

  • Simpler structure — no solution restructuring needed
  • No naming redundancy — avoids .WebApi.WebApi issue
  • Easy to navigate — single project, clear folder hierarchy
  • Lower complexity — appropriate for learning-focused PoC
  • Flexible — can upgrade to Full approach later if needed
  • Same benefits — clear separation of concerns, testability improvements
  • Better testability and maintainability
  • Clear architectural boundaries
  • Easy to swap implementations
  • Follows industry best practices

Cons ⚠️

  • Convention-based — layer boundaries enforced by code review, not compiler
  • Easier to violate — developers could accidentally import wrong namespaces
  • No compile-time protection — circular dependencies possible within project
  • More folders to navigate
  • Learning curve for developers new to Clean Architecture

Mitigation

  • Use code review to enforce layer dependencies
  • Consider adding architectural tests (e.g., ArchUnitNET) to validate layer rules
  • Document namespace conventions clearly

Future: Full Clean Architecture

When the project grows beyond a single bounded context or requires stricter boundaries, migrate to separate projects:

src/
├── Dotnet.Samples.AspNetCore.WebApi.Core/           # Domain
├── Dotnet.Samples.AspNetCore.WebApi.Application/    # Use Cases (or .UseCases)
├── Dotnet.Samples.AspNetCore.WebApi.Infrastructure/
└── Dotnet.Samples.AspNetCore.WebApi.Api/            # Presentation (not .WebApi!)

Migration triggers:

  • Multiple bounded contexts or aggregates
  • Team growth requiring enforced boundaries
  • Need to share domain logic across multiple applications
  • Microservices extraction planned

Note: The presentation layer uses .Api suffix (not .WebApi) to avoid the redundant Dotnet.Samples.AspNetCore.WebApi.WebApi naming.


References

Metadata

Metadata

Assignees

Labels

.NETPull requests that update .NET codeenhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions