In the previous post, we talked about how folder structure evolved from Simple MVC to Microservices — and why each approach emerged to solve the problems of the one before it.
This time we go somewhere more concrete: what does the code actually look like?
The approach: we build the same feature using all five architectures, then examine how each one is tested. That way, the differences can be compared directly — apples-to-apples.
---
The Scenario: Orders Feature
The feature we build across all architectures:
POST /orders — create a new orderGET /orders/{id} — fetch order detailsThis feature was chosen because it involves more than one layer — HTTP, business logic, database, and an external service. Real enough to show architectural differences, but not so complex it obscures the point.
Tech stack used across all examples:
| Need | Library |
|---|---|
| HTTP framework | ASP.NET Core Web API |
| ORM | Entity Framework Core |
| Unit test | xUnit + Moq + FluentAssertions |
| Integration test | WebApplicationFactory |
| Contract testing | PactNet |
| CQRS (Clean Arch) | MediatR |
Architecture 1: Simple MVC
Folder Structure
/Controllers
OrderController.cs
/Models
Order.cs
Implementation
In Simple MVC, all logic lives inside the controller. A single action method directly validates stock, saves the order via EF Core, and sends an email.
// Controllers/OrderController.cs
[ApiController]
[Route("[controller]")]
public class OrderController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IEmailSender _email;
public OrderController(AppDbContext db, IEmailSender email)
{
_db = db;
_email = email;
}
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request)
{
var product = await _db.Products.FindAsync(request.ProductId);
if (product is null || product.Stock < request.Quantity)
return BadRequest("Insufficient stock");
var order = new Order
{
ProductId = request.ProductId,
Quantity = request.Quantity,
CreatedAt = DateTime.UtcNow
};
_db.Orders.Add(order);
await _db.SaveChangesAsync();
await _email.SendAsync(request.CustomerEmail, ___CODE_BLOCK_PLACEHOLDER___1___CODE_BLOCK_PLACEHOLDER___quot;Order {order.Id} confirmed");
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
}
The code is readable and fast to write. But every time new logic is needed — discounts, fraud checks, audit logs — it all goes into the same method. Within a few sprints, this action can grow to hundreds of lines.
Testing Simple MVC
Because logic and infrastructure are mixed in one method, pure unit testing is not possible. The only option is integration testing: spin up the entire application and hit the endpoint.
In ASP.NET Core, this is done with WebApplicationFactory:
// OrderControllerTests.cs
public class OrderControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task PostOrders_WithValidProduct_ReturnsCreated()
{
var payload = new { ProductId = "prod-1", Quantity = 2, CustomerEmail = "user@test.com" };
var response = await _client.PostAsJsonAsync("/orders", payload);
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
}
Every test requires a database (even EF Core InMemory). Tests become slow and dependent on external state. There is no way to test edge cases — like "what happens when stock is exactly 0?" — without preparing database records first.
Best fit for: prototypes, small internal tools, validating ideas in 1–2 weeks.
---
Architecture 2: Layered Architecture
Folder Structure
/Controllers
OrderController.cs
/Services
IOrderService.cs
OrderService.cs
/Repositories
IOrderRepository.cs
OrderRepository.cs
/Models
Order.cs
Implementation
Business logic moves to OrderService. The controller's only job is to receive the request and delegate.
// Services/OrderService.cs
public class OrderService : IOrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailSender _email;
public OrderService(IOrderRepository repo, IEmailSender email)
{
_repo = repo;
_email = email;
}
public async Task<Order> CreateOrderAsync(CreateOrderDto dto)
{
var product = await _repo.FindProductByIdAsync(dto.ProductId);
if (product is null || product.Stock < dto.Quantity)
throw new InvalidOperationException("Insufficient stock");
var order = new Order { ProductId = dto.ProductId, Quantity = dto.Quantity };
await _repo.SaveAsync(order);
await _email.SendAsync(dto.CustomerEmail, ___CODE_BLOCK_PLACEHOLDER___4___CODE_BLOCK_PLACEHOLDER___quot;Order {order.Id} confirmed");
return order;
}
}
// Controllers/OrderController.cs
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(request.ToDto());
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
Testing Layered Architecture
Now OrderService can be tested in isolation by mocking IOrderRepository with Moq — no database needed:
// OrderServiceTests.cs
public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _mockRepo = new();
private readonly Mock<IEmailSender> _mockEmail = new();
private readonly OrderService _service;
public OrderServiceTests()
{
_service = new OrderService(_mockRepo.Object, _mockEmail.Object);
}
[Fact]
public async Task CreateOrderAsync_ThrowsWhenStockInsufficient()
{
_mockRepo.Setup(r => r.FindProductByIdAsync("prod-1"))
.ReturnsAsync(new Product { Stock = 0 });
var act = () => _service.CreateOrderAsync(
new CreateOrderDto { ProductId = "prod-1", Quantity = 2 });
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Insufficient stock");
}
[Fact]
public async Task CreateOrderAsync_SendsEmailAfterSuccess()
{
_mockRepo.Setup(r => r.FindProductByIdAsync("prod-1"))
.ReturnsAsync(new Product { Stock = 10 });
await _service.CreateOrderAsync(
new CreateOrderDto { ProductId = "prod-1", Quantity = 2, CustomerEmail = "u@test.com" });
_mockEmail.Verify(e => e.SendAsync("u@test.com", It.IsAny<string>()), Times.Once);
}
}
This is a significant step forward. Tests run fast and can cover many conditions without any infrastructure.
Best fit for: REST APIs with 5–15 endpoints, teams of 2–5 developers.
---
Architecture 3: Feature-Based Structure
Folder Structure
/Features
/Orders
OrderController.cs
IOrderService.cs
OrderService.cs
IOrderRepository.cs
OrderRepository.cs
Order.cs
CreateOrderDto.cs
OrderServiceTests.cs ← test lives inside the feature folder
/Notifications
INotificationService.cs
NotificationService.cs
Implementation
Same structure as Layered, but organized around business features instead of file types. When a developer is assigned to the Orders feature, everything they need is in one folder — including the tests.
OrderService now uses INotificationService as a separate abstraction:
// Features/Orders/OrderService.cs
public class OrderService : IOrderService
{
private readonly IOrderRepository _repo;
private readonly INotificationService _notifier;
public OrderService(IOrderRepository repo, INotificationService notifier)
{
_repo = repo;
_notifier = notifier;
}
public async Task<Order> CreateOrderAsync(CreateOrderDto dto)
{
var product = await _repo.FindProductByIdAsync(dto.ProductId);
if (product is null || product.Stock < dto.Quantity)
throw new InvalidOperationException("Insufficient stock");
var order = new Order { ProductId = dto.ProductId, Quantity = dto.Quantity };
await _repo.SaveAsync(order);
await _notifier.SendOrderConfirmationAsync(order.Id);
return order;
}
}
Testing Feature-Based Structure
The test file lives inside Features/Orders/ — not in a separate test project. Anyone opening the Orders folder immediately finds both code and tests.
// Features/Orders/OrderServiceTests.cs
public class OrderServiceTests
{
[Fact]
public async Task CreateOrderAsync_SendsNotificationAfterSuccess()
{
var mockRepo = new Mock<IOrderRepository>();
var mockNotifier = new Mock<INotificationService>();
mockRepo.Setup(r => r.FindProductByIdAsync("prod-1"))
.ReturnsAsync(new Product { Stock = 10 });
var service = new OrderService(mockRepo.Object, mockNotifier.Object);
await service.CreateOrderAsync(
new CreateOrderDto { ProductId = "prod-1", Quantity = 2 });
mockNotifier.Verify(
n => n.SendOrderConfirmationAsync(It.IsAny<string>()),
Times.Once);
}
}
The test pattern is identical to Layered, but the proximity of code and tests helps onboard new developers and reduces the risk of tests becoming "separated and forgotten".
Best fit for: SaaS products, teams of 5–20 developers, applications with a growing number of features.
---
Architecture 4: Clean Architecture
Folder Structure
/src
/Domain
/Orders
Order.cs ← pure entity
IOrderRepository.cs ← port (interface)
DomainException.cs
/Application
/Orders
CreateOrderCommand.cs
CreateOrderHandler.cs
/Infrastructure
/Database
EfOrderRepository.cs ← port implementation
/Email
SendGridNotificationAdapter.cs
/Presentation
/Controllers
OrderController.cs
Implementation
In Clean Architecture, dependencies must point inward — toward the domain and application layers. The Domain must not know about EF Core, HTTP, or SendGrid.
// Domain/Orders/Order.cs
public class Order
{
public string Id { get; private set; } = Guid.NewGuid().ToString();
public string ProductId { get; private set; }
public int Quantity { get; private set; }
public Order(string productId, int productStock, int quantity)
{
if (quantity > productStock)
throw new DomainException("Insufficient stock");
ProductId = productId;
Quantity = quantity;
}
}
// Domain/Orders/IOrderRepository.cs
public interface IOrderRepository
{
Task<Product?> FindProductByIdAsync(string productId);
Task<Order> SaveAsync(Order order);
}
// Application/Orders/CreateOrderHandler.cs
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, string>
{
private readonly IOrderRepository _repo;
private readonly INotificationPort _notifier;
public CreateOrderHandler(IOrderRepository repo, INotificationPort notifier)
{
_repo = repo;
_notifier = notifier;
}
public async Task<string> Handle(CreateOrderCommand command, CancellationToken ct)
{
var product = await _repo.FindProductByIdAsync(command.ProductId);
if (product is null)
throw new DomainException("Product not found");
var order = new Order(command.ProductId, product.Stock, command.Quantity);
var saved = await _repo.SaveAsync(order);
await _notifier.NotifyAsync(saved.Id);
return saved.Id;
}
}
CreateOrderHandler has no idea whether we're using EF Core, Dapper, or SQLite. It only knows IOrderRepository — a contract.
Testing Clean Architecture
There are three distinct test layers, each with a different purpose and speed.
Domain Entity — pure unit test, zero dependencies
// Domain/Orders/OrderTests.cs
public class OrderTests
{
[Fact]
public void Constructor_ThrowsWhenQuantityExceedsStock()
{
var act = () => new Order(productId: "prod-1", productStock: 3, quantity: 5);
act.Should().Throw<DomainException>()
.WithMessage("Insufficient stock");
}
[Fact]
public void Constructor_Succeeds_WhenQuantityWithinStock()
{
var order = new Order(productId: "prod-1", productStock: 10, quantity: 3);
order.ProductId.Should().Be("prod-1");
order.Quantity.Should().Be(3);
}
}
No mocks, no setup. These tests run in milliseconds and are never flaky.
Application Handler — mock port via Moq
// Application/Orders/CreateOrderHandlerTests.cs
public class CreateOrderHandlerTests
{
[Fact]
public async Task Handle_SavesOrderAndNotifies()
{
var mockRepo = new Mock<IOrderRepository>();
var mockNotifier = new Mock<INotificationPort>();
mockRepo.Setup(r => r.FindProductByIdAsync("prod-1"))
.ReturnsAsync(new Product { Id = "prod-1", Stock = 10 });
mockRepo.Setup(r => r.SaveAsync(It.IsAny<Order>()))
.ReturnsAsync((Order o) => o);
var handler = new CreateOrderHandler(mockRepo.Object, mockNotifier.Object);
await handler.Handle(
new CreateOrderCommand { ProductId = "prod-1", Quantity = 2 },
CancellationToken.None);
mockRepo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Once);
mockNotifier.Verify(n => n.NotifyAsync(It.IsAny<string>()), Times.Once);
}
}
The handler is tested without a database or HTTP stack. Moq only needs to mock the interface — not a concrete class.
Infrastructure — integration test via EF Core InMemory
// Infrastructure/Database/EfOrderRepositoryTests.cs
public class EfOrderRepositoryTests : IDisposable
{
private readonly AppDbContext _context;
public EfOrderRepositoryTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Build();
_context = new AppDbContext(options);
}
[Fact]
public async Task SaveAsync_PersistsOrderToDatabase()
{
var repo = new EfOrderRepository(_context);
var order = new Order("prod-1", productStock: 10, quantity: 2);
var saved = await repo.SaveAsync(order);
saved.Id.Should().NotBeNullOrEmpty();
_context.Orders.Should().ContainSingle();
}
public void Dispose() => _context.Dispose();
}
This is the only test that involves a database — and even then, just EF Core InMemory. In the CI pipeline, it can be separated from unit tests.
Key advantage: domain entity and handler can be tested 100% without a database, without HTTP, without any framework. Switch EF Core to Dapper? Only EfOrderRepository.cs changes.
Best fit for: fintech, healthcare, enterprise, systems expected to evolve over 5–10 years.
---
Architecture 5: Microservices
System Structure
In microservices, the Orders feature is no longer a single folder — it becomes several independent services running in separate processes and containers:
/order-service ← ASP.NET Core Web API
/notification-service ← ASP.NET Core Worker Service
/product-service ← ASP.NET Core Web API
/api-gateway ← YARP Reverse Proxy
Flow between services:
Client
│
▼
[api-gateway]
│
▼
[order-service] ──── HTTP GET ────► [product-service] (check stock)
│
│ publish event
▼
[Azure Service Bus / RabbitMQ] ◄── subscribe ── [notification-service]
order-service never directly calls notification-service. It only publishes an event to the message bus. notification-service subscribes and reacts asynchronously.
// order-service: after the order is saved
await _messageBus.PublishAsync(new OrderCreatedEvent
{
OrderId = order.Id,
CustomerEmail = command.CustomerEmail
});
Testing Microservices
Unit test per service
Identical to Layered or Clean Architecture internally. No difference here.
Contract testing with PactNet
A new problem emerges: how do we ensure the event payload published by order-service matches what notification-service expects?
The answer is contract testing — order-service defines the contract, notification-service verifies it, without both needing to run at the same time.
// order-service: consumer contract test
public class OrderEventConsumerTests
{
[Fact]
public async Task OrderService_PublishesCorrectPayload_ToNotificationService()
{
var pact = Pact.V3("order-service", "notification-service",
new PactConfig { PactDir = "./pacts" });
await pact
.UponReceiving("an OrderCreatedEvent notification")
.WithRequest(HttpMethod.Post, "/notify")
.WithJsonBody(new { orderId = Match.Type("order-123") })
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.VerifyAsync(async ctx =>
{
var client = new NotificationClient(ctx.MockServerUri);
await client.NotifyAsync("order-123");
});
}
}
E2E test via Docker Compose
For end-to-end verification, run all services with docker-compose up and hit the endpoint from outside:
docker-compose up -d
→ POST http://localhost:5000/orders
→ assert order created + email notification fired
Without contract testing, any change to the event DTO in order-service can silently break notification-service — and no one finds out until production.
Best fit for: teams of 50+ developers, platforms with uneven traffic, systems that must scale independently per feature.
---
Side-by-Side Comparison
| Aspect | Simple MVC | Layered | Feature-Based | Clean Arch | Modular Monolith | Microservices |
|---|---|---|---|---|---|---|
| Files for Orders feature | 2 | 5 | 7 | 9+ | 7–9 per module | per-service |
| Unit test business logic | Not possible | Yes | Yes | Very easy | Easy (per module) | Yes per service |
| Tests require a database? | Yes (always) | No | No | No | No | No per unit |
| Contract testing needed? | No | No | No | No | No | Yes (PactNet) |
| Test suite speed | Slow | Medium | Medium | Fast | Medium | Medium |
| Swap database | Change everything | Change repository | Change repository | Change 1 file | Change 1 file per module | Per service |
| Onboarding a new developer | Fast | Fast | Medium | Slow | Medium | Slow |
Conclusion
Every architecture can be tested. But how easy — and how meaningful — that testing is varies enormously.
Simple MVC works when speed is the priority and the system won't grow much. WebApplicationFactory is sufficient for testing at that scale.
Layered and Feature-Based are the sweet spot for most .NET applications. Moq makes mocking clean and expressive. Feature-Based adds the benefit of tests living right next to the code they verify.
Clean Architecture is an investment — it requires more files and more discipline. But the domain entity and handler can be tested as pure C#, without EF Core, without ASP.NET Core, without anything. That value compounds over time as business rules grow more complex.
Modular Monolith sits exactly between Feature-Based and Microservices — a single deployment unit, but with module boundaries enforced by tooling rather than convention. In .NET, the internal keyword and project separation provide compiler-level enforcement. Read more in the dedicated Modular Monolith post.
Microservices is no longer about folders — it's about systems. It introduces a new need — contract testing — that doesn't exist in any monolithic architecture. PactNet is the tooling to learn when teams start splitting the system.
Choose the architecture whose testing can be kept consistent by the team you have right now. Not the most sophisticated one — the most maintainable one.
---
Closing Thoughts
The right question isn't:
> "Which is the best architecture?"
It's:
> "Which architecture is easiest to test and maintain by my team, given the complexity we're facing right now?"
Code that can be tested is code that can be trusted. And code that can be trusted is the foundation of a system that can grow.