Loading content...
Understanding architectural patterns conceptually is essential—but it's only half the battle. The true test comes when you begin implementation. How do you structure your folders? What naming conventions support the architecture? How do you configure dependency injection? What are the common mistakes that derail even well-intentioned teams?
This page bridges the gap between theory and practice. We'll examine the concrete decisions that bring architectural patterns to life: project organization, coding conventions, team coordination, and the pitfalls that commonly undermine architectural intentions. By the end, you'll have actionable guidance for implementing whatever architecture you've chosen.
By the end of this page, you will be able to organize codebases to reflect architectural patterns, establish conventions that reinforce architectural boundaries, configure dependency injection properly, and avoid the common pitfalls that cause architectural decay in real projects.
Folder structure is the first visible manifestation of architecture. A well-designed structure makes architectural boundaries obvious; a poor one hides them or actively contradicts them.
Two Fundamental Approaches:
Most domain-centric architectures benefit from a hybrid approach: layer-based at the top level, feature-based within layers. Let's examine concrete examples.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
MyApplication/├── src/│ ├── Domain/ # Enterprise Business Rules (innermost)│ │ ├── Entities/│ │ │ ├── Order.cs # Core domain entity│ │ │ ├── Customer.cs│ │ │ └── Product.cs│ │ ├── ValueObjects/│ │ │ ├── Money.cs│ │ │ ├── Address.cs│ │ │ └── OrderId.cs│ │ ├── Aggregates/ # Aggregate roots│ │ │ ├── OrderAggregate.cs│ │ │ └── CustomerAggregate.cs│ │ ├── Events/ # Domain events│ │ │ ├── OrderPlacedEvent.cs│ │ │ └── OrderShippedEvent.cs│ │ ├── Interfaces/ # Port interfaces (defined by domain)│ │ │ ├── IOrderRepository.cs│ │ │ ├── IPaymentService.cs│ │ │ └── IEmailService.cs│ │ └── Exceptions/│ │ ├── InsufficientInventoryException.cs│ │ └── InvalidOrderStateException.cs│ ││ ├── Application/ # Application Business Rules│ │ ├── UseCases/ # Organized by feature│ │ │ ├── Orders/│ │ │ │ ├── PlaceOrder/│ │ │ │ │ ├── PlaceOrderCommand.cs│ │ │ │ │ ├── PlaceOrderHandler.cs│ │ │ │ │ └── PlaceOrderValidator.cs│ │ │ │ ├── CancelOrder/│ │ │ │ │ ├── CancelOrderCommand.cs│ │ │ │ │ └── CancelOrderHandler.cs│ │ │ │ └── GetOrderDetails/│ │ │ │ ├── GetOrderDetailsQuery.cs│ │ │ │ ├── GetOrderDetailsHandler.cs│ │ │ │ └── OrderDetailsDto.cs│ │ │ └── Payments/│ │ │ └── ProcessPayment/│ │ │ ├── ProcessPaymentCommand.cs│ │ │ └── ProcessPaymentHandler.cs│ │ ├── Common/│ │ │ ├── Behaviors/ # Cross-cutting concerns│ │ │ │ ├── LoggingBehavior.cs│ │ │ │ └── ValidationBehavior.cs│ │ │ └── Interfaces/│ │ │ ├── IDateTimeProvider.cs│ │ │ └── ICurrentUserService.cs│ │ └── Mappings/│ │ └── OrderMappingProfile.cs│ ││ ├── Infrastructure/ # Frameworks & Drivers (outermost)│ │ ├── Persistence/│ │ │ ├── Configurations/ # EF Core configs│ │ │ │ ├── OrderConfiguration.cs│ │ │ │ └── CustomerConfiguration.cs│ │ │ ├── Repositories/│ │ │ │ ├── OrderRepository.cs # Implements IOrderRepository│ │ │ │ └── CustomerRepository.cs│ │ │ ├── Migrations/│ │ │ └── AppDbContext.cs│ │ ├── ExternalServices/│ │ │ ├── Payment/│ │ │ │ └── StripePaymentService.cs│ │ │ └── Email/│ │ │ └── SendGridEmailService.cs│ │ ├── Identity/│ │ │ └── CurrentUserService.cs│ │ └── DependencyInjection.cs # DI configuration│ ││ └── WebApi/ # Interface Adapters (presentation)│ ├── Controllers/│ │ ├── OrdersController.cs│ │ └── PaymentsController.cs│ ├── Middleware/│ │ └── ExceptionHandlingMiddleware.cs│ ├── Filters/│ └── Program.cs│├── tests/│ ├── Domain.UnitTests/│ ├── Application.UnitTests/│ ├── Infrastructure.IntegrationTests/│ └── WebApi.IntegrationTests/│└── README.mdA well-designed folder structure is self-documenting. A new developer should be able to look at the top-level folders and immediately understand the architectural approach. Names like 'Domain', 'Application', 'Infrastructure' signal Clean Architecture; 'Core', 'Adapters' might signal Hexagonal.
Hexagonal Architecture uses different terminology, reflected in its folder structure. The concepts map directly to Clean Architecture but with port/adapter vocabulary.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
OrderingService/├── src/│ ├── Core/ # The Hexagon (Application Core)│ │ ├── Domain/ # Domain model│ │ │ ├── Order.cs│ │ │ ├── Customer.cs│ │ │ └── ValueObjects/│ │ │ ├── Money.cs│ │ │ └── OrderId.cs│ │ ├── Ports/ # Interfaces (inbound and outbound)│ │ │ ├── Inbound/ # Primary/Driving ports│ │ │ │ ├── IOrderingService.cs # Use cases as interface│ │ │ │ └── IInventoryQueryService.cs│ │ │ └── Outbound/ # Secondary/Driven ports│ │ │ ├── IOrderRepository.cs│ │ │ ├── IPaymentGateway.cs│ │ │ └── IEmailSender.cs│ │ ├── Services/ # Application services (implement inbound ports)│ │ │ ├── OrderingService.cs # Implements IOrderingService│ │ │ └── InventoryQueryService.cs│ │ └── Events/│ │ └── OrderPlaced.cs│ ││ └── Adapters/ # Outside the Hexagon│ ├── Inbound/ # Primary/Driving adapters│ │ ├── Web/│ │ │ ├── Controllers/│ │ │ │ └── OrdersController.cs│ │ │ └── DTOs/│ │ │ └── CreateOrderRequest.cs│ │ ├── CLI/│ │ │ └── OrderCommands.cs│ │ └── MessageQueue/│ │ └── OrderEventConsumer.cs│ ││ └── Outbound/ # Secondary/Driven adapters│ ├── Persistence/│ │ ├── SqlOrderRepository.cs # Implements IOrderRepository│ │ └── Configurations/│ │ └── OrderMapping.cs│ ├── Payment/│ │ └── StripePaymentGateway.cs│ └── Email/│ └── SmtpEmailSender.cs│└── tests/ ├── Core.Tests/ # Unit tests for core └── Adapters.Tests/ # Integration tests for adaptersKey Differences from Clean Architecture Structure:
The terminology matters less than consistency. Pick Clean Architecture vocabulary, Hexagonal vocabulary, or Onion vocabulary—and use it consistently throughout the codebase, documentation, and team communication.
Names are powerful tools for enforcing architecture. When names clearly signal architectural roles, violations become obvious. Let's establish conventions for each architectural element.
| Element | Convention | Examples |
|---|---|---|
| Entities | Noun, representing the object | Order, Customer, Product |
| Value Objects | Noun or descriptive name | Money, Address, EmailAddress |
| Aggregates | EntityAggregate or just Entity if obvious | OrderAggregate, CustomerAggregate |
| Domain Events | Past tense verb phrase | OrderPlacedEvent, PaymentProcessedEvent |
| Commands | Imperative verb phrase + Command | PlaceOrderCommand, CancelOrderCommand |
| Queries | Get/Find + noun + Query | GetOrderDetailsQuery, FindCustomersByRegionQuery |
| Handlers | Match command/query + Handler | PlaceOrderHandler, GetOrderDetailsHandler |
| Ports/Interfaces | I + noun + capability | IOrderRepository, IPaymentGateway, IEmailService |
| Adapters | Technology + Interface name (impl) | SqlOrderRepository, StripePaymentGateway |
| DTOs | Purpose + DTO or Request/Response | CreateOrderRequest, OrderDetailsDto |
| Controllers | Plural noun + Controller | OrdersController, PaymentsController |
Namespace/Package Naming:
Namespaces should mirror the folder structure and reinforce layer boundaries:
12345678910111213141516171819202122232425262728293031323334
// Domain Layer - Pure business logicnamespace MyApp.Domain.Entities { public class Order { }} namespace MyApp.Domain.ValueObjects{ public record Money(decimal Amount, string Currency);} namespace MyApp.Domain.Interfaces // Ports defined by domain{ public interface IOrderRepository { }} // Application Layer - Use casesnamespace MyApp.Application.Orders.PlaceOrder{ public record PlaceOrderCommand(/* ... */); public class PlaceOrderHandler { }} // Infrastructure Layer - Adaptersnamespace MyApp.Infrastructure.Persistence.Repositories{ public class SqlOrderRepository : IOrderRepository { }} // Presentation Layer - Controllersnamespace MyApp.WebApi.Controllers{ public class OrdersController : ControllerBase { }}When naming conventions are consistent, violations become jarring. If you see 'SqlOrderRepository' in the Domain namespace or 'Order' (entity) being imported into a Controller, the inconsistency is immediately visible during code review.
Dependency Injection (DI) is the mechanism that makes domain-centric architectures work. The DI container wires interfaces to implementations at composition time, allowing the domain to depend on abstractions while the actual implementations are provided from the infrastructure layer.
The Composition Root:
The Composition Root is the single location where all dependencies are configured. In ASP.NET Core, this is typically Program.cs or extension methods called from it.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Infrastructure/DependencyInjection.csnamespace MyApp.Infrastructure; public static class DependencyInjection{ public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { // Persistence services.AddDbContext<AppDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("Default"))); // Repository implementations (adapters for ports) services.AddScoped<IOrderRepository, SqlOrderRepository>(); services.AddScoped<ICustomerRepository, SqlCustomerRepository>(); // External service adapters services.AddScoped<IPaymentGateway, StripePaymentGateway>(); services.AddScoped<IEmailService, SendGridEmailService>(); // Infrastructure services services.AddScoped<IDateTimeProvider, SystemDateTimeProvider>(); return services; }} // Application/DependencyInjection.csnamespace MyApp.Application; public static class DependencyInjection{ public static IServiceCollection AddApplication(this IServiceCollection services) { // Register MediatR for CQRS handlers services.AddMediatR(cfg => { cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); }); // Register validation pipeline services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); // AutoMapper for DTO mapping services.AddAutoMapper(typeof(DependencyInjection).Assembly); return services; }} // WebApi/Program.cs (Composition Root)var builder = WebApplication.CreateBuilder(args); // Add layers in dependency orderbuilder.Services.AddApplication(); // Application layerbuilder.Services.AddInfrastructure( // Infrastructure layer builder.Configuration); var app = builder.Build();Even teams that understand architectural concepts make implementation mistakes. Here are the most common pitfalls and how to avoid them:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ❌ ANEMIC: Entity has no behaviorpublic class Order{ public Guid Id { get; set; } public List<OrderLine> Lines { get; set; } public decimal Total { get; set; } public string Status { get; set; }} public class OrderService{ public void PlaceOrder(Order order) { // All logic in service - entity is just a data bag order.Total = order.Lines.Sum(l => l.Quantity * l.UnitPrice); if (order.Total < 0) throw new Exception("Invalid total"); order.Status = "Placed"; _repository.Save(order); }} // ✅ RICH: Entity encapsulates behaviorpublic class Order{ private readonly List<OrderLine> _lines = new(); public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); public Money Total { get; private set; } public OrderStatus Status { get; private set; } public void AddLine(Product product, int quantity) { if (Status != OrderStatus.Draft) throw new InvalidOrderStateException("Cannot modify placed order"); var line = new OrderLine(product, quantity); _lines.Add(line); RecalculateTotal(); } public void Place() { if (!_lines.Any()) throw new InvalidOperationException("Cannot place empty order"); Status = OrderStatus.Placed; AddDomainEvent(new OrderPlacedEvent(this)); } private void RecalculateTotal() { Total = _lines.Aggregate(Money.Zero, (sum, line) => sum + line.Total); }}Conway's Law states that organizations design systems mirroring their communication structures. For architecture to succeed, team organization must align with—or at least not contradict—architectural boundaries.
Team Structures That Support Architecture:
| Team Structure | Best For | Architectural Alignment |
|---|---|---|
| Feature Teams | Cross-functional delivery | Teams own features across all layers; need architectural guardrails |
| Component/Layer Teams | Deep expertise per layer | Natural ownership of layers; risk of handoff overhead |
| Domain Teams | Complex domains | Align with bounded contexts; each team owns a domain's full stack |
| Platform Teams | Shared infrastructure | Own common adapters, shared infrastructure layer |
Practical Recommendations:
Some organizations deliberately restructure teams to drive architectural outcomes (the 'Inverse Conway Maneuver'). If you want microservices, create small autonomous teams. If you want a cohesive monolith, keep teams closely integrated. Structure follows strategy.
One of the primary benefits of domain-centric architecture is testability. Each layer can be tested appropriately, with the testing pyramid naturally emerging from the architecture.
| Layer | Test Type | Dependencies | Speed | Coverage Focus |
|---|---|---|---|---|
| Domain | Unit Tests | None (pure code) | Very Fast | Business rules, entity behavior, value objects |
| Application | Unit Tests | Mocked ports | Fast | Use case orchestration, workflows |
| Infrastructure | Integration Tests | Real dependencies | Slow | Adapter correctness, database queries |
| Presentation/API | Integration Tests | In-memory application | Medium | Request/response mapping, routing |
| Full System | E2E Tests | All real | Very Slow | Critical paths, smoke tests |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// DOMAIN LAYER: Pure unit tests - no dependencies[Fact]public void Order_AddLine_IncreasesTotalCorrectly(){ // Arrange var order = new Order(CustomerId.New()); var product = new Product("Widget", Money.FromUsd(10)); // Act order.AddLine(product, quantity: 3); // Assert order.Total.Should().Be(Money.FromUsd(30));} // APPLICATION LAYER: Unit tests with mocked ports[Fact]public async Task PlaceOrder_ValidOrder_SavesAndPublishesEvent(){ // Arrange var mockRepo = new Mock<IOrderRepository>(); var mockPublisher = new Mock<IEventPublisher>(); var handler = new PlaceOrderHandler(mockRepo.Object, mockPublisher.Object); var command = new PlaceOrderCommand(/* ... */); // Act await handler.Handle(command, CancellationToken.None); // Assert mockRepo.Verify(r => r.Add(It.IsAny<Order>()), Times.Once); mockPublisher.Verify(p => p.Publish(It.IsAny<OrderPlacedEvent>()), Times.Once);} // INFRASTRUCTURE LAYER: Integration tests with real DB[Fact]public async Task SqlOrderRepository_Add_PersistsOrder(){ // Arrange using var context = CreateTestDbContext(); // In-memory or testcontainers var repository = new SqlOrderRepository(context); var order = CreateTestOrder(); // Act await repository.Add(order); await context.SaveChangesAsync(); // Assert var loaded = await context.Orders.FindAsync(order.Id); loaded.Should().NotBeNull(); loaded.Total.Should().Be(order.Total);} // API LAYER: Integration tests with WebApplicationFactory[Fact]public async Task POST_Orders_ValidRequest_Returns201(){ // Arrange await using var factory = new WebApplicationFactory<Program>(); var client = factory.CreateClient(); var request = new CreateOrderRequest { /* ... */ }; // Act var response = await client.PostAsJsonAsync("/api/orders", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created);}With proper architecture, the testing pyramid (many unit tests, fewer integration tests, few E2E tests) emerges naturally. Domain and application layers have thousands of fast unit tests. Infrastructure has hundreds of slower integration tests. A handful of E2E tests validate complete flows.
Use this checklist when implementing domain-centric architecture. It serves as a quality gate for architectural compliance:
We've completed a comprehensive exploration of how to choose, implement, and maintain software architecture. Let's consolidate everything we've learned across this module:
The Ultimate Insight:
Architecture is not a one-time decision but an ongoing practice. The best architects are not those who design perfect systems from the start (impossible), but those who make good initial choices and skillfully adapt as reality unfolds.
Your architecture should serve your goals—not the reverse. Be pragmatic, be thoughtful, and always remember: the goal isn't architectural purity, but building systems that work, scale, and remain maintainable over time.
Congratulations! You've completed the module on Choosing an Architecture. You now have a comprehensive framework for comparing, selecting, evolving, and implementing architectural patterns. You're equipped to make informed architectural decisions for any project context and to guide those architectures through their full lifecycle.