Home / Blog / Engineering
Engineering

Folder Structure in Practice: One Feature, Five Architectures (C# / .NET)

Building the same Orders feature using Simple MVC, Layered, Feature-Based, Clean Architecture, and Microservices — with testing strategies for each, using real code examples in C# and ASP.NET Core.

Yudi Nugraha
May 3, 2026
13 min read

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 order
  • GET /orders/{id} — fetch order details
  • Validation: product stock must be available before the order is created
  • Notification: send a confirmation email after the order succeeds
  • This 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:

    NeedLibrary
    HTTP frameworkASP.NET Core Web API
    ORMEntity Framework Core
    Unit testxUnit + Moq + FluentAssertions
    Integration testWebApplicationFactory
    Contract testingPactNet
    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 testingorder-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

    AspectSimple MVCLayeredFeature-BasedClean ArchModular MonolithMicroservices
    Files for Orders feature2579+7–9 per moduleper-service
    Unit test business logicNot possibleYesYesVery easyEasy (per module)Yes per service
    Tests require a database?Yes (always)NoNoNoNoNo per unit
    Contract testing needed?NoNoNoNoNoYes (PactNet)
    Test suite speedSlowMediumMediumFastMediumMedium
    Swap databaseChange everythingChange repositoryChange repositoryChange 1 fileChange 1 file per modulePer service
    Onboarding a new developerFastFastMediumSlowMediumSlow
    ---

    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.

    Tags

    Software EngineeringArchitectureC#dotNETTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    More Articles

    Explore more articles on similar topics

    View All Articles