Clean Architecture dengan .NET: Panduan Praktis

Kuasai Clean Architecture di .NET dengan C#. Pelajari prinsip SOLID, pemisahan lapisan, dan pola implementasi untuk aplikasi yang mudah dipelihara.

Panduan Clean Architecture dengan .NET dan C#

Clean Architecture, yang dipopulerkan oleh Robert C. Martin (Uncle Bob), mengatur kode dengan menempatkan logika bisnis di pusat aplikasi, terpisah dari framework dan detail implementasi. Pendekatan arsitektur ini menjamin testabilitas, kemudahan pemeliharaan, dan skalabilitas aplikasi .NET. Panduan ini menampilkan implementasi praktis dengan ASP.NET Core.

Mengapa Clean Architecture?

Aplikasi yang mencampur logika bisnis dengan kode infrastruktur dengan cepat menjadi sulit dipelihara. Clean Architecture menerapkan pemisahan ketat sehingga detail teknis dapat diubah tanpa memengaruhi inti bisnis.

Prinsip Dasar Clean Architecture

Clean Architecture bertumpu pada inversi ketergantungan: lapisan dalam tidak mengetahui lapisan luar. Domain bisnis tetap terisolasi dan dapat berkembang secara independen dari pilihan teknis seperti framework web atau basis data.

text
┌─────────────────────────────────────────────────────────┐
│                     Presentation                        │
│  (Controllers, Razor Pages, Blazor, API Endpoints)     │
├─────────────────────────────────────────────────────────┤
│                    Infrastructure                       │
│    (EF Core, External APIs, File System, Email)        │
├─────────────────────────────────────────────────────────┤
│                      Application                        │
│       (Use Cases, Commands, Queries, DTOs)             │
├─────────────────────────────────────────────────────────┤
│                        Domain                           │
│     (Entities, Value Objects, Domain Services)          │
└─────────────────────────────────────────────────────────┘

Ketergantungan selalu mengarah ke dalam: Presentation → Infrastructure → Application → Domain. Domain tidak merujuk pada proyek lain mana pun.

Struktur Proyek .NET dalam Clean Architecture

Organisasi proyek mencerminkan lapisan-lapisan yang ada. Setiap lapisan berkorelasi dengan proyek terpisah dalam solusi Visual Studio, sehingga memastikan pemisahan tanggung jawab secara fisik.

bash
# terminal
# Membuat struktur solusi
dotnet new sln -n CleanArchitecture

# Membuat proyek untuk setiap lapisan
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api

# Menambahkan proyek ke solusi
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api

# Mengonfigurasi referensi antar proyek
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain

cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application

cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.Infrastructure

Struktur ini memastikan Domain tetap independen dan bahwa ketergantungan mengikuti Dependency Rule pada Clean Architecture.

Lapisan Domain: Inti Bisnis

Lapisan Domain memuat entitas bisnis, Value Object, dan antarmuka repositori. Tidak ada ketergantungan eksternal yang diizinkan pada lapisan ini.

Domain/Entities/Order.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entitas dengan identitas dan siklus hidup bisnis
public class Order
{
    // Pengenal unik pesanan
    public Guid Id { get; private set; }

    // Referensi pelanggan (Value Object untuk email)
    public string CustomerEmail { get; private set; }

    // Koleksi item (relasi one-to-many)
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // Status pesanan (enum bisnis)
    public OrderStatus Status { get; private set; }

    // Total nilai yang dihitung
    public decimal TotalAmount => _items.Sum(i => i.TotalPrice);

    // Tanggal pelacakan
    public DateTime CreatedAt { get; private set; }
    public DateTime? ShippedAt { get; private set; }

    // Konstruktor privat memaksa penggunaan factory method
    private Order() { }

    // Factory method untuk membuat pesanan yang valid
    public static Order Create(string customerEmail)
    {
        // Validasi aturan bisnis saat pembuatan
        if (string.IsNullOrWhiteSpace(customerEmail))
            throw new DomainException("Email pelanggan wajib diisi.");

        if (!IsValidEmail(customerEmail))
            throw new DomainException("Format email tidak valid.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerEmail = customerEmail,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
    }

    // Metode bisnis: menambahkan item
    public void AddItem(Product product, int quantity)
    {
        // Aturan bisnis: pesanan yang sudah dikirim tidak dapat diubah
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Pesanan yang sudah dikirim tidak dapat diubah.");

        if (quantity <= 0)
            throw new DomainException("Jumlah harus positif.");

        // Memeriksa apakah produk sudah ada
        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(this, product, quantity));
        }
    }

    // Metode bisnis: konfirmasi pesanan
    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Hanya pesanan yang menunggu yang dapat dikonfirmasi.");

        if (!_items.Any())
            throw new DomainException("Pesanan harus memuat minimal satu item.");

        Status = OrderStatus.Confirmed;
    }

    // Metode bisnis: kirim pesanan
    public void Ship()
    {
        if (Status != OrderStatus.Confirmed)
            throw new DomainException("Pesanan harus dikonfirmasi sebelum dikirim.");

        Status = OrderStatus.Shipped;
        ShippedAt = DateTime.UtcNow;
    }

    private static bool IsValidEmail(string email) =>
        email.Contains('@') && email.Contains('.');
}

Entitas Order mengenkapsulasi aturan bisnisnya dan melindungi keadaan internalnya. Perubahan harus melewati metode bisnis yang memvalidasi invarian.

Domain/Entities/OrderItem.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entitas anak dengan identitas tersendiri
public class OrderItem
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public decimal UnitPrice { get; private set; }
    public int Quantity { get; private set; }

    // Perhitungan total harga baris
    public decimal TotalPrice => UnitPrice * Quantity;

    private OrderItem() { }

    // Factory method dengan validasi
    public static OrderItem Create(Order order, Product product, int quantity)
    {
        return new OrderItem
        {
            Id = Guid.NewGuid(),
            OrderId = order.Id,
            ProductId = product.Id,
            ProductName = product.Name,
            UnitPrice = product.Price,
            Quantity = quantity
        };
    }

    // Metode untuk menambah jumlah
    public void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new DomainException("Jumlah tambahan harus positif.");

        Quantity += additionalQuantity;
    }
}
Domain/Enums/OrderStatus.cscsharp
namespace CleanArchitecture.Domain.Enums;

// Enumerasi status pesanan
public enum OrderStatus
{
    Pending = 0,      // Menunggu konfirmasi
    Confirmed = 1,    // Dikonfirmasi, siap dikirim
    Shipped = 2,      // Dikirim
    Delivered = 3,    // Diterima
    Cancelled = 4     // Dibatalkan
}
Value Objects

Value Object seperti Money, Address, atau Email mengenkapsulasi konsep bisnis tanpa identitas tersendiri. Kesetaraannya didasarkan pada nilai, bukan referensi. Pendekatan ini memperkuat ekspresivitas domain.

Antarmuka Repository di Domain

Antarmuka repositori didefinisikan di Domain, sedangkan implementasinya berada di Infrastructure. Pola ini menjunjung prinsip inversi ketergantungan.

Domain/Interfaces/IOrderRepository.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Antarmuka repositori Order
public interface IOrderRepository
{
    // Pengambilan berdasarkan pengenal
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    // Pengambilan beserta item
    Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);

    // Pengambilan berdasarkan email pelanggan
    Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default);

    // Menambahkan pesanan baru
    Task AddAsync(Order order, CancellationToken cancellationToken = default);

    // Memperbarui pesanan yang sudah ada
    Task UpdateAsync(Order order, CancellationToken cancellationToken = default);

    // Menghapus pesanan
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
Domain/Interfaces/IUnitOfWork.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Pola Unit of Work untuk manajemen transaksi
public interface IUnitOfWork : IDisposable
{
    // Repositori yang dapat diakses melalui UoW
    IOrderRepository Orders { get; }
    IProductRepository Products { get; }

    // Penyimpanan atomik untuk seluruh perubahan
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);

    // Manajemen transaksi eksplisit
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

Pola Unit of Work mengoordinasikan operasi pada beberapa repositori dalam satu transaksi.

Siap menguasai wawancara .NET Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Lapisan Application: Orkestrasi Use Case

Lapisan Application memuat logika aplikasi (use case), DTO, dan antarmuka layanan eksternal. Lapisan ini mengorkestrasi interaksi antara domain dan dunia luar.

Application/Orders/Commands/CreateOrderCommand.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Commands;

// Command yang merepresentasikan niat membuat pesanan
public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderItemDto> Items
) : IRequest<Guid>;

// DTO untuk item pesanan
public record OrderItemDto(
    Guid ProductId,
    int Quantity
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Commands;

// Handler yang mengimplementasikan use case
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Membuat entitas Order melalui factory method
        var order = Order.Create(request.CustomerEmail);

        // Menambahkan item ke pesanan
        foreach (var item in request.Items)
        {
            // Mengambil produk dari repositori
            var product = await _unitOfWork.Products
                .GetByIdAsync(item.ProductId, cancellationToken);

            if (product == null)
                throw new NotFoundException($"Produk {item.ProductId} tidak ditemukan.");

            // Menggunakan metode bisnis entitas
            order.AddItem(product, item.Quantity);
        }

        // Menyimpan melalui repositori
        await _unitOfWork.Orders.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

MediatR menerapkan pola Mediator untuk memisahkan handler dari controller. Setiap command memiliki satu handler yang bertanggung jawab atas pemrosesannya.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Queries;

// Query untuk mengambil pesanan berdasarkan ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// DTO respons untuk sebuah pesanan
public record OrderDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt,
    DateTime? ShippedAt,
    List<OrderItemResponseDto> Items
);

// DTO untuk item pada respons
public record OrderItemResponseDto(
    Guid Id,
    string ProductName,
    decimal UnitPrice,
    int Quantity,
    decimal TotalPrice
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Queries;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDto?> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        // Mengambil beserta item
        var order = await _orderRepository
            .GetByIdWithItemsAsync(request.OrderId, cancellationToken);

        if (order == null)
            return null;

        // Pemetaan ke DTO (proyeksi manual)
        return new OrderDto(
            Id: order.Id,
            CustomerEmail: order.CustomerEmail,
            Status: order.Status.ToString(),
            TotalAmount: order.TotalAmount,
            CreatedAt: order.CreatedAt,
            ShippedAt: order.ShippedAt,
            Items: order.Items.Select(i => new OrderItemResponseDto(
                Id: i.Id,
                ProductName: i.ProductName,
                UnitPrice: i.UnitPrice,
                Quantity: i.Quantity,
                TotalPrice: i.TotalPrice
            )).ToList()
        );
    }
}

Pemisahan Commands/Queries mengikuti pola CQRS (Command Query Responsibility Segregation), mengoptimalkan baca dan tulis secara independen.

Validasi dengan FluentValidation

Validasi command terjadi di lapisan Application sebelum handler dieksekusi.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;

namespace CleanArchitecture.Application.Orders.Validators;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        // Email wajib dan format harus valid
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().WithMessage("Email pelanggan wajib diisi.")
            .EmailAddress().WithMessage("Format email tidak valid.");

        // Minimal satu item wajib ada
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Pesanan harus memuat minimal satu item.");

        // Validasi setiap item
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId)
                .NotEmpty().WithMessage("Pengenal produk wajib diisi.");

            item.RuleFor(i => i.Quantity)
                .GreaterThan(0).WithMessage("Jumlah harus positif.")
                .LessThanOrEqualTo(100).WithMessage("Jumlah maksimum: 100.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
using FluentValidation;
using MediatR;

namespace CleanArchitecture.Application.Common.Behaviors;

// Pipeline behavior untuk validasi otomatis
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Jika tidak ada validator, lanjutkan
        if (!_validators.Any())
            return await next();

        // Menjalankan semua validator
        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        // Mengagregasi error
        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        // Melempar exception jika ada error
        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

ValidationBehavior berjalan secara otomatis sebelum setiap handler, memastikan hanya command yang valid yang sampai ke logika bisnis.

Lapisan Infrastructure: Implementasi Teknis

Lapisan Infrastructure menyediakan implementasi konkret dari antarmuka yang didefinisikan di Domain dan Application.

Infrastructure/Persistence/AppDbContext.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Menerapkan konfigurasi dari assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence.Configurations;

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        // Tabel dan kunci primer
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        // Properti
        builder.Property(o => o.CustomerEmail)
            .IsRequired()
            .HasMaxLength(256);

        builder.Property(o => o.Status)
            .IsRequired()
            .HasConversion<string>();  // Disimpan sebagai string yang mudah dibaca

        // Relasi dengan OrderItems
        builder.HasMany(o => o.Items)
            .WithOne()
            .HasForeignKey(i => i.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        // Indeks untuk pencarian email
        builder.HasIndex(o => o.CustomerEmail);

        // Akses ke field privat _items
        builder.Navigation(o => o.Items)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;

namespace CleanArchitecture.Infrastructure.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<Order?> GetByIdWithItemsAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        // Eager loading untuk item
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public Task UpdateAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        // EF Core menelusuri perubahan secara otomatis
        _context.Orders.Update(order);
        return Task.CompletedTask;
    }

    public async Task DeleteAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders
            .Where(o => o.Id == id)
            .ExecuteDeleteAsync(cancellationToken);
    }
}
Waspadai Leaky Abstractions

Repositori tidak boleh memaparkan IQueryable secara langsung karena akan menciptakan ketergantungan pada EF Core di lapisan atas. Gunakan metode spesifik dengan parameter yang jelas.

Implementasi Unit of Work

Infrastructure/Persistence/UnitOfWork.cscsharp
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IDbContextTransaction? _transaction;

    // Lazy loading repositori
    private IOrderRepository? _orderRepository;
    private IProductRepository? _productRepository;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    // Pembuatan sesuai kebutuhan (lazy initialization)
    public IOrderRepository Orders =>
        _orderRepository ??= new OrderRepository(_context);

    public IProductRepository Products =>
        _productRepository ??= new ProductRepository(_context);

    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }

    public async Task BeginTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        _transaction = await _context.Database
            .BeginTransactionAsync(cancellationToken);
    }

    public async Task CommitTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Tidak ada transaksi aktif.");

        await _transaction.CommitAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Tidak ada transaksi aktif.");

        await _transaction.RollbackAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

Konfigurasi Dependency Injection

Pendaftaran layanan dilakukan di setiap lapisan melalui extension method, kemudian diatur di Program.cs.

Infrastructure/DependencyInjection.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Konfigurasi Entity Framework
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));

        // Pendaftaran Unit of Work (Scoped)
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Repositori individual jika diperlukan
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
Application/DependencyInjection.cscsharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;

namespace CleanArchitecture.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        var assembly = typeof(DependencyInjection).Assembly;

        // Mendaftarkan MediatR beserta handler
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));

        // Mendaftarkan validator FluentValidation
        services.AddValidatorsFromAssembly(assembly);

        // Pipeline behaviors (urutan eksekusi penting)
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

        return services;
    }
}
Api/Program.cscsharp
using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Mendaftarkan lapisan
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

// Konfigurasi API
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Lapisan Presentation: Controller API

Controller adalah adaptor sederhana yang mendelegasikan pekerjaan ke MediatR.

Api/Controllers/OrdersController.cscsharp
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;

namespace CleanArchitecture.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        // Delegasi penuh ke MediatR
        var orderId = await _mediator.Send(command, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = orderId },
            orderId);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var order = await _mediator.Send(
            new GetOrderByIdQuery(id),
            cancellationToken);

        if (order == null)
            return NotFound();

        return Ok(order);
    }

    [HttpPost("{id:guid}/confirm")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Confirm(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
        return NoContent();
    }

    [HttpPost("{id:guid}/ship")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Ship(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
        return NoContent();
    }
}

Controller tetap ramping dan tidak memuat logika bisnis, hanya pemetaan HTTP.

Unit Test untuk Lapisan Application

Clean Architecture mempermudah unit testing berkat isolasi ketergantungan.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Tests.Application;

public class CreateOrderCommandHandlerTests
{
    private readonly Mock<IUnitOfWork> _unitOfWorkMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        // Arrange
        var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };

        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _unitOfWorkMock.Setup(x => x.Orders.AddAsync(
            It.IsAny<Order>(),
            It.IsAny<CancellationToken>()))
            .Returns(Task.CompletedTask);

        _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: product.Id, Quantity: 2)
            });

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotEqual(Guid.Empty, result);
        _unitOfWorkMock.Verify(x => x.Orders.AddAsync(
            It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(x => x.SaveChangesAsync(
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_ProductNotFound_ThrowsNotFoundException()
    {
        // Arrange
        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 1)
            });

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _handler.Handle(command, CancellationToken.None));
    }
}

Mock dari Moq memungkinkan handler diisolasi dari ketergantungannya, sehingga hanya logika orkestrasi yang diuji.

Kesimpulan

Clean Architecture dengan .NET menyediakan struktur yang kokoh untuk aplikasi enterprise. Pemisahan ketat tanggung jawab antara lapisan Domain, Application, Infrastructure, dan Presentation menjamin kode yang mudah dipelihara dan diuji dalam jangka panjang.

Checklist Clean Architecture .NET

  • ✅ Domain terisolasi tanpa ketergantungan eksternal
  • ✅ Entitas dengan logika bisnis terenkapsulasi
  • ✅ Antarmuka repositori berada di Domain
  • ✅ Use Case melalui Commands/Queries (CQRS)
  • ✅ Validasi dengan FluentValidation dan Pipeline Behaviors
  • ✅ Infrastructure mengimplementasikan antarmuka Domain
  • ✅ Controller ramping yang mendelegasikan ke MediatR
  • ✅ Unit test terisolasi dengan mock

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Investasi awal pada arsitektur ini cepat menghasilkan keuntungan pada proyek menengah hingga besar, di mana perubahan kebutuhan teknis (migrasi basis data, pergantian framework) hanya memengaruhi lapisan luar dan menjaga inti bisnis tetap utuh.

Tag

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Bagikan

Artikel terkait