Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Modular Monolith — #03

.NET'te Modular Monolith Solution Yapısı: Module Projeleri, internal ve Module Registration

Mert Özen 7 Kas 2026 13 dk 4 görüntülenme
.NET'te Modular Monolith Solution Yapısı: Module Projeleri, internal ve Module Registration

Her module için iki project açılır: diğer module'lerin görebildiği küçük bir Contracts ve her şeyin internal olduğu bir Core; Host yalnız AddXModule() ve MapXEndpoints() çağıran composition root'tur. Boş solution'dan çalışan üç module'lü Host'a gidiyor, project reference ve internal'ın sınırı nerede koruyup nerede korumadığını gerçek CS0234/CS0122 hatalarıyla, Shared Kernel'e neyin girip neyin giremeyeceğini somut bir listeyle gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'da Ordering module'ünü yazan developer'ın acelesi var. Sipariş ekranı için bir kitabın liste fiyatına ihtiyacı var ve Catalog'un contract'ında bu bilgiyi veren method henüz yok. Catalog'un içinde fiyatı tutan BookStore class'ını görüyor ve Bookstore.Ordering.Core project'ine Bookstore.Catalog.Core için bir project reference ekliyor. dotnet add reference komutu sorunsuz çalışıyor.

Üç soru. Kod derlenir mi? Derlenmezse hangi hata? Ve asıl soru: Catalog'u yazan arkadaş "sorun değil" deyip Catalog'un project'ine InternalsVisibleTo Include="Bookstore.Ordering.Core" satırını eklerse ne olur? Derleyici bu kez ne der, sınıra ne olur?

Module Başına İki Project: Contracts ve Core

Bir module'ü .NET'e dökmenin en yaygın ve en anlaşılır yolu, her module için iki project açmak. Bookstore.Ordering.Contracts module'ün dışarıya söz verdiği her şeyi tutar: diğer module'lerin çağırabileceği interface'ler, bu interface'lerin kullandığı record'lar ve id tipleri, ileride de integration event'ler. Bookstore.Ordering.Core ise module'ün geri kalanı: entity'ler, iş kuralları, veri erişimi, endpoint'ler. Core'daki hemen her şey internal. Diğer module'ler yalnız Contracts'a referans verebilir.

Bu ayrım .NET'in kendi kütüphanelerinde de her yerde. Logging'in contract'ı ile implementation'ı ayrı assembly'lerde duruyor:

using System.Reflection;

// Contract (Abstractions) ile implementation ayrı assembly'lerde
string[] assemblies =
[
    "Microsoft.Extensions.Logging.Abstractions",
    "Microsoft.Extensions.Logging",
    "Microsoft.Extensions.Logging.Console"
];
foreach (string name in assemblies)
{
    Assembly assembly = Assembly.Load(name);
    IEnumerable<string?> refs = assembly.GetReferencedAssemblies()
        .Select(r => r.Name).Where(n => n!.StartsWith("Microsoft.Extensions.Logging")).Order();
    int publicTypes = assembly.GetExportedTypes().Length;
    Console.WriteLine($"{name,-42} public types: {publicTypes,3}  -> [{string.Join(", ", refs)}]");
}
Microsoft.Extensions.Logging.Abstractions  public types:  24  -> []
Microsoft.Extensions.Logging               public types:   8  -> [Microsoft.Extensions.Logging.Abstractions]
Microsoft.Extensions.Logging.Console       public types:  14  -> [Microsoft.Extensions.Logging.Abstractions, Microsoft.Extensions.Logging.Configuration]

ILogger ve ILoggerFactory gibi contract'lar Abstractions assembly'sinde ve bu assembly başka hiçbir logging assembly'sini tanımıyor. Bir library yalnız Abstractions'a bağlanarak log yazabiliyor; hangi implementation'ın çalışacağına uygulamanın composition root'u karar veriyor. İlginç olan son satır: Console provider'ı bile implementation'ı değil, yalnız Abstractions'ı tanıyor. Bookstore'da Ordering'in Catalog ile ilişkisi de böyle olacak.

Bazı ekipler module başına dört project açıyor. Kamil Grzybek'in GitHub'daki modular-monolith-with-ddd örneği her module'ü Domain, Application, Infrastructure ve IntegrationEvents assembly'lerine bölüyor ve README'de ilk üçünün tek assembly'de birleşebileceğini kendisi not ediyor. Module'ün içini nasıl katmanlayacağın ayrı bir karar; Clean/Onion/Hexagonal ve Vertical Slice serilerinin konusu. Module sınırı için önemli olan tek şey, dışarıya açık kısımla içerinin ayrı derlenmesi.

Boş Solution'dan Başlamak

Üç module'lü iskeleti şu komutlarla kurduk:

dotnet new sln -n Bookstore
dotnet new web -n Bookstore.Host -o src/Bookstore.Host
dotnet new classlib -n Bookstore.SharedKernel -o src/Bookstore.SharedKernel
dotnet new classlib -n Bookstore.BuildingBlocks -o src/Bookstore.BuildingBlocks
for m in Catalog Ordering Inventory; do
  dotnet new classlib -n Bookstore.$m.Contracts -o src/Modules/$m/Bookstore.$m.Contracts
  dotnet new classlib -n Bookstore.$m.Core -o src/Modules/$m/Bookstore.$m.Core
  dotnet add src/Modules/$m/Bookstore.$m.Contracts reference src/Bookstore.SharedKernel
  dotnet add src/Modules/$m/Bookstore.$m.Core reference src/Modules/$m/Bookstore.$m.Contracts src/Bookstore.BuildingBlocks
  dotnet add src/Bookstore.Host reference src/Modules/$m/Bookstore.$m.Core
done
# Inventory, kitabı Catalog'un yayınladığı BookId ile tanır
dotnet add src/Modules/Inventory/Bookstore.Inventory.Contracts reference src/Modules/Catalog/Bookstore.Catalog.Contracts
# Ordering yalnız diğer module'lerin Contracts project'lerini görür
dotnet add src/Modules/Ordering/Bookstore.Ordering.Core reference \
  src/Modules/Catalog/Bookstore.Catalog.Contracts src/Modules/Inventory/Bookstore.Inventory.Contracts
dotnet new console -n Bookstore.Ordering.Tests -o tests/Bookstore.Ordering.Tests
dotnet add tests/Bookstore.Ordering.Tests package xunit.v3 --version 4.0.2
dotnet add tests/Bookstore.Ordering.Tests reference src/Modules/Ordering/Bookstore.Ordering.Core

Bu SDK (Software Development Kit) sürümünde (10.0.402) dotnet new, oluşturduğu project'i klasördeki solution'a kendisi ekliyor; daha eski SDK'larda sona bir dotnet sln add satırı gerekir. Solution dosyası da artık varsayılan olarak .slnx formatında. Ortak ayarlar her project'te tekrar etmesin diye solution kökünde bir Directory.Build.props var; MSBuild bu dosyayı klasör ağacında yukarı doğru arayıp her project'e otomatik ekliyor:

<Project>
  <!-- Solution'daki her project bu ayarları miras alır -->
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>14</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Ortaya çıkan referans grafiği, bu serinin geri kalanında koruyacağımız kural:

03-project-references-tr.png

Contracts: Interface, Record ve Id

Catalog'un contract'ı küçük:

using Bookstore.SharedKernel;

namespace Bookstore.Catalog.Contracts;

public readonly record struct BookId(Guid Value);

public sealed record BookSummary(BookId Id, string Isbn, string Title, Money ListPrice);

// Catalog'un diğer module'lere açtığı tek kapı
public interface ICatalogModuleApi
{
    Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default);
}

Contracts project'inde ne olmalı sorusunun cevabı, "diğer module'ün bilmesi gereken en az şey". BookSummary bir record: davranışı yok, Catalog'un Book entity'sinin dışarıya gösterdiği bir fotoğraf. BookId bir readonly record struct; Guid'leri birbirine karıştırmayı derleyiciye yakalatıyor. Contracts'ta EF (Entity Framework) Core attribute'u, DbContext, entity, repository interface'i ya da "ileride lazım olur" diye eklenmiş bir alan olmamalı. Bunlardan biri Contracts'a girdiği an, Catalog'un iç modeli Ordering'in derlemesine sızmış olur.

Inventory'nin contract'ı da kitabı Catalog'un BookId'siyle tanıyor, bu yüzden Inventory.Contracts, Catalog.Contracts'a referans veriyor. Bu bir sızıntı değil: Catalog, kitabın kimliğini herkesin kullanacağı bir published language olarak yayınlıyor, Inventory de buna uyuyor. Contracts'tan Contracts'a referans makul; Core'dan başka bir module'ün Core'una referans değil.

Core: Her Şey internal

Catalog'un içi:

using Bookstore.SharedKernel;

namespace Bookstore.Catalog.Core;

// Module'ün içi: entity dışarıdan görünmez
internal sealed class Book(Guid id, string isbn, string title, string author, Money listPrice)
{
    public Guid Id { get; } = id;
    public string Isbn { get; } = isbn;
    public string Title { get; } = title;
    public string Author { get; } = author;
    public Money ListPrice { get; private set; } = listPrice;

    public void ChangePrice(Money newPrice) => ListPrice = newPrice;
}

internal sealed class BookStore
{
    public static readonly Guid Dune = Guid.Parse("0199b1a0-0000-7000-8000-000000000001");
    public static readonly Guid Emma = Guid.Parse("0199b1a0-0000-7000-8000-000000000002");

    private readonly Dictionary<Guid, Book> _books = new()
    {
        [Dune] = new(Dune, "978-0441013593", "Dune", "Frank Herbert", Money.Eur(12.50m)),
        [Emma] = new(Emma, "978-0141439587", "Emma", "Jane Austen", Money.Eur(6.00m))
    };

    public IEnumerable<Book> All => _books.Values;
    public Book? Find(Guid id) => _books.GetValueOrDefault(id);
}
using Bookstore.Catalog.Contracts;

namespace Bookstore.Catalog.Core;

internal sealed class CatalogModuleApi(BookStore store) : ICatalogModuleApi
{
    public Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default) =>
        Task.FromResult(store.Find(id.Value) is { } book
            ? new BookSummary(new BookId(book.Id), book.Isbn, book.Title, book.ListPrice)
            : null);
}

Book, BookStore ve CatalogModuleApi internal. Dışarıdan bakan biri yalnız ICatalogModuleApi'yi görebilir ve onu DI (Dependency Injection) container'ından ister. Catalog yarın BookStore'u EF Core'a, Dapper'a ya da bir arama motoruna taşırsa Ordering'in tek satırı değişmez.

Module Registration ve Endpoint Group'ları

Her module dışarıya iki extension method açıyor: biri service'lerini kaydediyor, diğeri endpoint'lerini bağlıyor. Bu, ASP.NET Core'un kendi alışkanlığı: AddAuthentication/UseAuthentication, AddHealthChecks/MapHealthChecks. ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core'da Dependency Injection: Lifetime'lar ve Captive Dependency Tuzağı" yazısında lifetime'ları konuşmuştuk; burada her module kendi lifetime kararlarını kendi içinde veriyor.

using Bookstore.Catalog.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Catalog.Core;

// Module'ün public yüzü: kayıt ve endpoint'ler, başka hiçbir şey
public static class CatalogModule
{
    public static IServiceCollection AddCatalogModule(this IServiceCollection services) =>
        services
            .AddSingleton<BookStore>()
            .AddScoped<ICatalogModuleApi, CatalogModuleApi>();

    public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder group = app.MapGroup("/api/catalog").WithTags("Catalog");
        group.MapGet("/books", (BookStore store) =>
            store.All.Select(b => new BookResponse(b.Id, b.Title, b.ListPrice.Amount, b.ListPrice.Currency)));
        group.MapGet("/books/{id:guid}", (Guid id, BookStore store) =>
            store.Find(id) is { } b
                ? Results.Ok(new BookResponse(b.Id, b.Title, b.ListPrice.Amount, b.ListPrice.Currency))
                : Results.NotFound());
        return app;
    }
}

internal sealed record BookResponse(Guid Id, string Title, decimal Price, string Currency);

MapGroup, module'ün bütün endpoint'lerini tek bir route prefix'i altında topluyor; filtre, authorization ya da OpenAPI tag'i gibi ortak ayarlar da group'a bir kez ekleniyor. Endpoint'in döndürdüğü BookResponse da internal: HTTP (Hypertext Transfer Protocol) yüzeyindeki DTO (Data Transfer Object) ile module'ler arası contract farklı şeyler ve farklı hızlarda değişebilirler.

Ordering'in handler'ı diğer module'leri yalnız contract'larıyla tanıyor:

using Bookstore.BuildingBlocks;
using Bookstore.Catalog.Contracts;
using Bookstore.Inventory.Contracts;

namespace Bookstore.Ordering.Core;

internal sealed record PlaceOrderRequest(Guid BookId, int Quantity);
internal sealed record OrderConfirmation(Guid OrderId, string Title, int Quantity, decimal Total, string Currency);

// Diğer module'leri yalnız Contracts üzerinden tanır
internal sealed class PlaceOrderHandler(ICatalogModuleApi catalog, IInventoryModuleApi inventory, OrderStore orders)
{
    public async Task<Result<OrderConfirmation>> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
    {
        BookSummary? book = await catalog.GetBookAsync(new BookId(request.BookId), ct);
        if (book is null)
            return new Error("order.unknown-book", $"Book {request.BookId} does not exist");

        int available = await inventory.GetAvailableAsync(book.Id, ct);
        if (available < request.Quantity)
            return new Error("order.out-of-stock", $"Only {available} of '{book.Title}' available");

        Order order = new(Guid.CreateVersion7(), [new OrderItem(book.Id.Value, book.Title, book.ListPrice, request.Quantity)]);
        orders.Add(order);
        return new OrderConfirmation(order.Id, book.Title, request.Quantity, order.Total.Amount, order.Total.Currency);
    }
}
using Bookstore.BuildingBlocks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Ordering.Core;

public static class OrderingModule
{
    public static IServiceCollection AddOrderingModule(this IServiceCollection services) =>
        services
            .AddSingleton<OrderStore>()
            .AddScoped<PlaceOrderHandler>();

    public static IEndpointRouteBuilder MapOrderingEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder group = app.MapGroup("/api/ordering").WithTags("Ordering");
        group.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
        {
            Result<OrderConfirmation> result = await handler.HandleAsync(request, ct);
            return result.IsSuccess
                ? Results.Created($"/api/ordering/orders/{result.Value!.OrderId}", result.Value)
                : Results.Problem(title: result.Error!.Code, detail: result.Error.Message, statusCode: 409);
        });
        return app;
    }
}

Host: Yalnız Composition Root

Host project'inde iş kuralı yok. Tek görevi module'leri bir araya getirmek:

using Bookstore.Catalog.Core;
using Bookstore.Inventory.Core;
using Bookstore.Ordering.Core;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Host yalnız composition root: module'leri kaydeder, endpoint'lerini bağlar
builder.Services
    .AddCatalogModule()
    .AddInventoryModule()
    .AddOrderingModule();

WebApplication app = builder.Build();
app.MapCatalogEndpoints();
app.MapInventoryEndpoints();
app.MapOrderingEndpoints();
app.Run();

Çalıştırıp denedik:

$ curl -s localhost:5103/api/catalog/books
[{"id":"0199b1a0-0000-7000-8000-000000000001","title":"Dune","price":12.50,"currency":"EUR"},{"id":"0199b1a0-0000-7000-8000-000000000002","title":"Emma","price":6.00,"currency":"EUR"}]
$ curl -s -X POST localhost:5103/api/ordering/orders -H "Content-Type: application/json" -d '{"bookId":"0199b1a0-0000-7000-8000-000000000001","quantity":2}'
{"orderId":"01a1b875-1a67-7008-95f7-a983ef6b5ca0","title":"Dune","quantity":2,"total":25.00,"currency":"EUR"}
$ curl -s -X POST localhost:5103/api/ordering/orders -H "Content-Type: application/json" -d '{"bookId":"0199b1a0-0000-7000-8000-000000000002","quantity":1}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"order.out-of-stock","status":409,"detail":"Only 0 of 'Emma' available"}

Emma'nın bir nüshası depoda var ama başka bir sipariş için ayrılmış; Inventory'nin contract'ı "kullanılabilir 0" diyor ve Ordering bunu 409 ile bildiriyor. Bu bilgi Ordering'e Inventory'nin tablosundan değil, IInventoryModuleApi'den geldi.

Host'un ince kalması, 08. yazıdaki "module'ü service'e çıkarmak" adımının ön koşulu. Payment'ı bir gün ayrı bir process'e taşıdığında, o process'in Host'u da aynı AddPaymentsModule() çağrısını yapacak.

internal ve InternalsVisibleTo: Yalnız Test İçin

Açılıştaki soruya dönelim. Ordering'e Catalog'un içinden fiyat okuyan bir kısayol ekledik:

using Bookstore.Catalog.Core;

namespace Bookstore.Ordering.Core;

// "Contract'a method eklemek uzun sürer" diye Catalog'un içine uzanmak
internal static class PriceShortcut
{
    public static decimal PriceOf(BookStore store, Guid id) => store.Find(id)!.ListPrice.Amount;
}

Project reference yokken derleyici namespace'i bile bulamıyor:

src/Modules/Ordering/Bookstore.Ordering.Core/PriceShortcut.cs(1,25): error CS0234: The type or namespace name 'Core' does not exist in the namespace 'Bookstore.Catalog' (are you missing an assembly reference?)
src/Modules/Ordering/Bookstore.Ordering.Core/PriceShortcut.cs(8,35): error CS0246: The type or namespace name 'BookStore' could not be found (are you missing a using directive or an assembly reference?)

dotnet add reference ile Catalog.Core'u ekleyince hata değişiyor ama gitmiyor:

src/Modules/Ordering/Bookstore.Ordering.Core/PriceShortcut.cs(8,35): error CS0122: 'BookStore' is inaccessible due to its protection level

İlk savunma hattı project reference, ikincisi internal. Ama dikkat: project reference'ı eklemek hiçbir hata vermedi. Catalog'un CatalogModule gibi public kalması gereken type'ları artık Ordering'den çağrılabilir ve Catalog'a InternalsVisibleTo Include="Bookstore.Ordering.Core" eklendiği gün ikinci hat da düşer; kod derlenir ve sınır sessizce kaybolur. Derleyici seni "Ordering, Catalog.Core'a referans veremez" kuralından korumuyor; o kuralı 07. yazıda architecture test'leri ve bir MSBuild kontrolüyle zorlayacağız.

InternalsVisibleTo'nun meşru tek kullanımı module'ün kendi test project'i. .NET 5'ten beri bunu attribute yerine MSBuild item'ı olarak yazabiliyorsun:

<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Bookstore.Ordering.Contracts\Bookstore.Ordering.Contracts.csproj" />
    <ProjectReference Include="..\..\..\Bookstore.BuildingBlocks\Bookstore.BuildingBlocks.csproj" />
    <ProjectReference Include="..\..\Catalog\Bookstore.Catalog.Contracts\Bookstore.Catalog.Contracts.csproj" />
    <ProjectReference Include="..\..\Inventory\Bookstore.Inventory.Contracts\Bookstore.Inventory.Contracts.csproj" />
  </ItemGroup>
  <ItemGroup>
    <InternalsVisibleTo Include="Bookstore.Ordering.Tests" />
  </ItemGroup>
</Project>

Test'ler, diğer module'leri contract'larının sahte implementation'larıyla temsil ediyor:

using Bookstore.BuildingBlocks;
using Bookstore.Catalog.Contracts;
using Bookstore.Inventory.Contracts;
using Bookstore.Ordering.Core;
using Bookstore.SharedKernel;
using Xunit;

namespace Bookstore.Ordering.Tests;

public sealed class PlaceOrderHandlerTests
{
    private static readonly BookId Dune = new(Guid.CreateVersion7());

    // Diğer module'ler contract'larının sahte implementation'larıyla temsil ediliyor
    private sealed class FakeCatalog : ICatalogModuleApi
    {
        public Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default) =>
            Task.FromResult<BookSummary?>(id == Dune ? new BookSummary(Dune, "978-0441013593", "Dune", Money.Eur(12.50m)) : null);
    }

    private sealed class FakeInventory(int available) : IInventoryModuleApi
    {
        public Task<int> GetAvailableAsync(BookId bookId, CancellationToken ct = default) => Task.FromResult(available);
    }

    [Fact]
    public async Task Places_order_with_catalog_price()
    {
        PlaceOrderHandler handler = new(new FakeCatalog(), new FakeInventory(3), new OrderStore());
        Result<OrderConfirmation> result = await handler.HandleAsync(new PlaceOrderRequest(Dune.Value, 2), TestContext.Current.CancellationToken);
        Assert.Equal(25.00m, result.Value!.Total);
    }

    [Fact]
    public async Task Rejects_order_when_stock_is_short()
    {
        PlaceOrderHandler handler = new(new FakeCatalog(), new FakeInventory(1), new OrderStore());
        Result<OrderConfirmation> result = await handler.HandleAsync(new PlaceOrderRequest(Dune.Value, 2), TestContext.Current.CancellationToken);
        Assert.Equal("order.out-of-stock", result.Error!.Code);
    }
}

Test run summary: Passed!
  total: 2
  failed: 0
  succeeded: 2
  skipped: 0
  duration: 763ms

İlk yazıda ASP.NET Core'un routing assembly'sinin InternalsVisibleTo listesine bakmıştık: listedeki her şey test, benchmark ya da mock proxy'siydi. Bookstore için kural aynı: içini yalnız kendi test'ine aç.

Shared Kernel: Ne Olmalı, Ne Olmamalı

Her modular monolith'te bir "ortak" project olur ve en büyük risk orada. Eric Evans'ın DDD (Domain-Driven Design) kitabındaki Shared Kernel, iki ekibin birlikte sahiplendiği, değişikliği ancak birlikte yapabildiği küçük bir model parçası. Bookstore'da DDD serisinin context map'inde Ordering ile Payments arasında tam olarak bir tane var: Money.

using System.Globalization;

namespace Bookstore.SharedKernel;

// Ordering ve Payments'ın aynı anlamda kullandığı tek domain kavramı
public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Eur(decimal amount) => new(amount, "EUR");

    public static Money operator +(Money left, Money right) =>
        left.Currency == right.Currency
            ? new(left.Amount + right.Amount, left.Currency)
            : throw new InvalidOperationException($"Cannot add {left.Currency} to {right.Currency}");

    public static Money operator *(Money price, int quantity) => new(price.Amount * quantity, price.Currency);

    public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"{Amount:0.00} {Currency}");
}

Domain'den bağımsız teknik yapı taşları ise ayrı bir project'te, Bookstore.BuildingBlocks'ta duruyor. Contract method'larının beklenen hataları exception fırlatmadan döndürebilmesi için küçük bir Result tipi bunlardan biri:

namespace Bookstore.BuildingBlocks;

public sealed record Error(string Code, string Message);

// Teknik yapı taşı: module contract'larının beklenen hataları exception'sız döndürmesi için
public sealed class Result<T>
{
    private Result(T? value, Error? error) => (Value, Error) = (value, error);

    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static implicit operator Result<T>(T value) => new(value, null);
    public static implicit operator Result<T>(Error error) => new(default, error);
}

Ayrımın kuralı basit:

  • Girebilir: Money gibi birden fazla module'de birebir aynı anlamı taşıyan, nadiren değişen value object'ler; Result, Error, integration event'lerin ortak interface'i gibi domain bilgisi taşımayan teknik tipler.
  • Giremez: entity'ler (Customer, Book), DbContext, repository'ler, module'e özgü enum'lar (OrderStatus), "iki module de kullanıyor" diye taşınan DTO'lar, iş kuralı içeren helper'lar.

"Girebilir mi?" sorusunu cevaplamak için bir test kullanabilirsin: bu tipte bir değişiklik olduğunda, onu kullanan bütün module'lerin aynı anda ve aynı nedenle değişmesi gerekiyor mu? Cevap evetse Shared Kernel adayı; hayırsa kopyala.

Sık Düşülen Tuzaklar

Tek bir Infrastructure project'ini tüm module'lerin paylaşması

Bookstore.Infrastructure içinde tek bir BookstoreDbContext, bütün repository'ler ve bütün dış entegrasyonlar. Module'ler kendi aralarında temiz görünür ama hepsi aynı project üzerinden birbirinin tablosuna ulaşabilir. Altyapı module başına olmalı; ortak olan yalnız teknik yapı taşlarıdır (connection string okuma, outbox tablosunun şeması gibi). Module başına DbContext 06. yazının konusu.

Contracts project'ine entity sızdırmak

"Ordering de Book'u kullanıyor, Contracts'a taşıyalım." Bu tek satırlık değişiklikle Catalog'un iç modeli public contract'a dönüşür ve Catalog artık Book'a property eklerken bile Ordering'i düşünmek zorunda kalır. Contracts'a entity değil, entity'nin o kullanım için gereken fotoğrafı girer.

InternalsVisibleTo'yu module'ler arasında kullanmak

Açılıştaki soru. Derleyiciyi susturur, sınırı kaldırır. Bir module'ün internal'ine erişme ihtiyacı doğduysa ya contract eksiktir ya da iki module aslında tek module'dür.

Host'a iş mantığı koymak

"Sipariş sonrası e-posta gönderimini Program.cs'e yazalım, iki module'ü de görüyor." Host her module'ü gördüğü için, oraya yazılan her satır bütün sınırların üstünden geçer. Host'ta yalnız kayıt, configuration ve endpoint bağlama olmalı.

Küçük Bir Deneme

★☆☆ Solution'a Payments module'ünü ekle: Bookstore.Payments.Contracts ve Bookstore.Payments.Core, içinde AddPaymentsModule() ve MapPaymentsEndpoints(). Host'a iki satır eklemenin yettiğini doğrula.

★★☆ Bookstore.Catalog.Contracts project'ine yanlışlıkla Bookstore.Catalog.Core'a referans ekle. Build ne diyor? Bu hatayı Contracts'ın asla Core'a bağımlı olmaması kuralının doğal bir bekçisi olarak nasıl yorumlarsın?

★★★ Directory.Build.props yanına bir Directory.Build.targets yaz: bir *.Core project'i başka bir module'ün *.Core project'ine referans verirse build'i bir hata mesajıyla durdursun. 07. yazıda aynı kuralı birlikte yazacağız; kendi çözümünle karşılaştır.

Bu yazıda module'leri .NET solution'ına döktük ve sınırın ilk iki bekçisini, project reference'ı ve internal'ı gördük. Bir sonraki yazıda module'lerin birbirini nasıl çağırması gerektiğine odaklanıyoruz: contract'ın şekli, çağrı yönü, chatty iletişimin maliyeti ve en sık yapılan hata, başka bir module'ün DbContext'ini inject etmek.