Beranda / Blog / Engineering
Engineering

Struktur Folder dalam Praktik: Satu Fitur, Lima Arsitektur (C# / .NET)

Membangun fitur Orders yang sama menggunakan Simple MVC, Layered, Feature-Based, Clean Architecture, dan Microservices — dilengkapi strategi testing untuk masing-masing, dengan contoh kode nyata menggunakan C# dan ASP.NET Core.

Yudi Nugraha
3 Mei 2026
12 menit baca

Di post sebelumnya, kita membahas bagaimana struktur folder berkembang dari Simple MVC hingga Microservices — dan mengapa setiap pendekatan lahir untuk menjawab masalah pada tahap sebelumnya.

Kali ini kita masuk ke ranah yang lebih konkret: kodenya seperti apa?

Strateginya: kita bangun satu fitur yang sama menggunakan kelima arsitektur, lalu lihat bagaimana cara masing-masing diuji. Dengan begitu, perbedaannya bisa dibandingkan secara langsung — apples-to-apples.

---

Skenario: Fitur Orders

Fitur yang kita bangun di semua arsitektur:

  • POST /orders — buat order baru
  • GET /orders/{id} — ambil detail order
  • Validasi: stok produk harus tersedia sebelum order dibuat
  • Notifikasi: kirim email konfirmasi setelah order berhasil
  • Fitur ini dipilih karena melibatkan lebih dari satu layer — ada HTTP, business logic, database, dan external service. Cukup nyata untuk menunjukkan perbedaan arsitektur, tapi tidak terlalu kompleks hingga mengaburkan poin utamanya.

    Tech stack yang digunakan di semua contoh:

    KebutuhanLibrary
    HTTP frameworkASP.NET Core Web API
    ORMEntity Framework Core
    Unit testxUnit + Moq + FluentAssertions
    Integration testWebApplicationFactory
    Contract testingPactNet
    CQRS (Clean Arch)MediatR
    ---

    Arsitektur 1: Simple MVC

    Struktur Folder

    /Controllers
      OrderController.cs
    /Models
      Order.cs
    

    Implementasi

    Di Simple MVC, semua logic ada di dalam OrderController. Controller langsung melakukan validasi stok, menyimpan order ke database via EF Core, dan mengirim email — semuanya dalam satu action method.

    // 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);
        }
    }
    

    Kode ini mudah dibaca dan cepat ditulis. Tapi setiap kali ada logika baru — diskon, fraud check, audit log — semuanya masuk ke method yang sama.

    Pengujian di Simple MVC

    Karena logic dan infrastruktur bercampur di satu tempat, unit test murni tidak mungkin dilakukan. Satu-satunya pilihan adalah integration test: jalankan seluruh aplikasi dan hit endpoint-nya.

    Di ASP.NET Core, ini dilakukan dengan 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);
        }
    }
    

    Setiap test butuh database (meski pakai EF Core InMemory). Test menjadi lambat dan bergantung pada state eksternal. Tidak ada cara menguji edge case — misalnya "apa yang terjadi kalau stok 0?" — tanpa menyiapkan data di database terlebih dahulu.

    Cocok untuk: prototype, internal tool kecil, validasi ide dalam 1–2 minggu.

    ---

    Arsitektur 2: Layered Architecture

    Struktur Folder

    /Controllers
      OrderController.cs
    /Services
      IOrderService.cs
      OrderService.cs
    /Repositories
      IOrderRepository.cs
      OrderRepository.cs
    /Models
      Order.cs
    

    Implementasi

    Business logic pindah ke OrderService. Controller hanya bertugas menerima request dan mendelegasikan ke service.

    // 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);
    }
    

    Pengujian di Layered Architecture

    Sekarang OrderService bisa diuji secara terisolasi dengan meng-mock IOrderRepository menggunakan Moq — tanpa database:

    // 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 = "user@test.com" });
    
            _mockEmail.Verify(e => e.SendAsync("user@test.com", It.IsAny<string>()), Times.Once);
        }
    }
    

    Ini langkah maju yang signifikan. Test berjalan cepat dan bisa menguji berbagai kondisi tanpa infrastruktur apapun.

    Catatan: IEmailSender harus ikut di-mock di setiap test service — ini mulai terasa verbose saat jumlah dependency bertambah.

    Cocok untuk: REST API 5–15 endpoint, tim 2–5 developer.

    ---

    Arsitektur 3: Feature-Based Structure

    Struktur Folder

    /Features
      /Orders
        OrderController.cs
        IOrderService.cs
        OrderService.cs
        IOrderRepository.cs
        OrderRepository.cs
        Order.cs
        CreateOrderDto.cs
        OrderServiceTests.cs    ← test ada di dalam folder fitur
      /Notifications
        INotificationService.cs
        NotificationService.cs
    

    Implementasi

    Struktur yang sama dengan Layered, tapi diorganisir berdasarkan fitur bisnis, bukan jenis file. Ketika developer ditugaskan mengerjakan fitur Orders, semua yang dibutuhkan ada di satu tempat — termasuk test-nya.

    OrderService kini menggunakan INotificationService sebagai abstraksi terpisah:

    // 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;
        }
    }
    

    Pengujian di Feature-Based Structure

    Test file hidup di dalam folder Features/Orders/, bukan di folder test terpisah. Ini membuat konteks lebih jelas — siapa pun yang membuka folder Orders langsung menemukan kode dan test-nya sekaligus.

    // 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);
        }
    }
    

    Pola test-nya identik dengan Layered, tapi kedekatan antara kode dan test membantu onboarding developer baru dan mengurangi risiko test yang "terpisah dan terlupakan".

    Cocok untuk: SaaS product, tim 5–20 developer, aplikasi dengan banyak fitur yang terus bertambah.

    ---

    Arsitektur 4: Clean Architecture

    Struktur Folder

    /src
      /Domain
        /Orders
          Order.cs              ← pure entity
          IOrderRepository.cs   ← port (interface)
          DomainException.cs
      /Application
        /Orders
          CreateOrderCommand.cs
          CreateOrderHandler.cs
      /Infrastructure
        /Database
          EfOrderRepository.cs  ← implementasi port
        /Email
          SendGridNotificationAdapter.cs
      /Presentation
        /Controllers
          OrderController.cs
    

    Implementasi

    Di Clean Architecture, dependency harus mengarah ke dalam — ke domain dan application layer — bukan ke luar. Domain tidak boleh tahu tentang EF Core, HTTP, atau 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 tidak tahu kita pakai EF Core, Dapper, atau SQLite. Ia hanya tahu IOrderRepository — sebuah kontrak.

    Pengujian di Clean Architecture

    Ini bagian yang paling menarik. Ada tiga lapisan test yang masing-masing punya tujuan berbeda.

    Domain Entity — pure unit test, nol dependency

    // 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);
        }
    }
    

    Tidak ada mock, tidak ada setup, tidak ada database. Test ini berjalan dalam milidetik dan tidak pernah 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);
        }
    }
    

    Handler diuji tanpa database dan tanpa HTTP stack. Moq hanya perlu meng-mock interface — bukan class konkret.

    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();
    }
    

    Test ini satu-satunya yang melibatkan database — dan itu pun hanya EF Core InMemory, bukan database nyata. Di CI pipeline, test ini bisa dipisah dari unit test biasa.

    Keunggulan utama: domain entity dan handler bisa di-test 100% tanpa database, tanpa HTTP, tanpa framework. Ganti EF Core ke Dapper? Hanya EfOrderRepository.cs yang berubah — tidak ada yang lain.

    Cocok untuk: fintech, healthcare, enterprise, sistem yang akan berkembang selama 5–10 tahun.

    ---

    Arsitektur 5: Microservices

    Struktur Sistem

    Di microservices, fitur Orders bukan lagi satu folder — melainkan beberapa service independen:

    /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 antar service:

    Client
      │
      ▼
    [api-gateway]
      │
      ▼
    [order-service] ──── HTTP GET ────► [product-service]  (cek stok)
      │
      │ publish event
      ▼
    [message queue]  ◄── subscribe ──── [notification-service]
    

    order-service tidak langsung memanggil notification-service. Ia hanya mempublish event ke message queue (RabbitMQ atau Azure Service Bus). notification-service subscribe dan bereaksi secara asynchronous.

    // order-service: setelah order tersimpan
    await _messageBus.PublishAsync(new OrderCreatedEvent
    {
        OrderId = order.Id,
        CustomerEmail = command.CustomerEmail
    });
    

    Pengujian di Microservices

    Unit test per service

    Identik dengan Layered atau Clean Architecture secara internal. Tidak ada perbedaan di sini.

    Contract testing dengan PactNet

    Masalah baru muncul: bagaimana memastikan payload event yang di-publish order-service sesuai dengan yang diharapkan notification-service?

    Jawabannya adalah contract testingorder-service mendefinisikan kontrak, notification-service memverifikasinya, tanpa keduanya harus berjalan bersamaan.

    // 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

    Untuk verifikasi end-to-end, jalankan semua service menggunakan docker-compose up dan hit endpoint dari luar:

    docker-compose up -d
    → POST http://localhost:5000/orders
    → assert order created + email notification fired
    

    Tanpa contract testing, perubahan sekecil apapun pada payload event di order-service bisa memecah notification-service secara diam-diam — dan baru diketahui saat sudah di production.

    Cocok untuk: tim 50+ developer, platform dengan traffic tidak merata, sistem yang harus di-scale secara independen per fitur.

    ---

    Perbandingan Side-by-Side

    AspekSimple MVCLayeredFeature-BasedClean ArchModular MonolithMicroservices
    Jumlah file untuk fitur Order2579+7–9 per modulper-service
    Unit test business logicTidak bisaBisaBisaSangat mudahMudah (per modul)Bisa per service
    Test butuh database?Ya (selalu)TidakTidakTidakTidakTidak per unit
    Contract testing dibutuhkan?TidakTidakTidakTidakTidakYa (PactNet)
    Kecepatan test suiteLambatSedangSedangCepatSedangSedang
    Ganti databaseUbah semuaUbah repositoryUbah repositoryUbah 1 fileUbah 1 file per modulPer service
    Onboarding developer baruCepatCepatSedangLambatSedangLambat
    ---

    Kesimpulan

    Semua arsitektur bisa di-test. Tapi cara dan kemudahannya sangat berbeda.

    Semakin jelas batas antar layer, semakin mudah setiap bagian diuji secara terisolasi.

    Simple MVC cocok saat kecepatan adalah prioritas dan sistem tidak akan berkembang jauh. Testing-nya berat tapi acceptable untuk ukuran itu.

    Layered dan Feature-Based adalah sweet spot untuk sebagian besar aplikasi bisnis. Testing mudah dilakukan, struktur cukup jelas, dan overhead-nya tidak berlebihan.

    Clean Architecture adalah investasi — butuh lebih banyak file dan disiplin, tapi domain logic benar-benar terlindungi dan bisa di-test murni. Nilainya terasa seiring waktu, terutama saat bisnis rules makin kompleks.

    Modular Monolith duduk tepat di antara Feature-Based dan Microservices — satu deployment unit, tapi batas modul ditegakkan oleh tooling bukan sekadar konvensi. Di .NET, internal keyword dan project separation memberikan enforcement level compiler. Baca lebih lanjut di post khusus Modular Monolith.

    Microservices bukan tentang folder lagi, tapi tentang sistem. Ia membuka kebutuhan baru — contract testing — yang tidak ada di arsitektur monolitik manapun.

    Pilih arsitektur yang testingnya bisa dijaga konsisten oleh tim yang ada sekarang. Bukan yang paling canggih — yang paling bisa dipertahankan.

    ---

    Penutup

    Pertanyaan yang tepat bukan:

    > "Arsitektur mana yang terbaik?"

    Tapi:

    > "Arsitektur mana yang paling mudah diuji dan dipertahankan oleh tim saya, untuk kompleksitas yang kita hadapi sekarang?"

    Kode yang bisa diuji adalah kode yang bisa dipercaya. Dan kode yang bisa dipercaya adalah fondasi dari sistem yang bisa tumbuh.

    Tag

    Software EngineeringArchitectureC#dotNETTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    Artikel Lainnya

    Jelajahi lebih banyak artikel dengan topik serupa

    Lihat Semua Artikel