Minimal API

Структура проєкту: від хаосу до архітектури

Еволюція організації Minimal API проєкту: від одного файлу до Extension Methods, Route Groups, Feature Folders та Vertical Slice Architecture.

Структура проєкту: від хаосу до архітектури

Протягом 14 попередніх розділів ми дописували весь код у Program.cs. Для навчання це зручно, але для реального проєкту — катастрофа. У цьому розділі ми пройдемо еволюцію організації Minimal API проєкту: від єдиного файлу через Extension Methods та Route Groups до Feature Folders та Vertical Slice Architecture. Кожен крок — це відповідь на конкретний біль, який виникає при зростанні проєкту.

1. Проблема: єдиний Program.cs

Уявіть, що ви створили API для інтернет-магазину. Спочатку це було 3 ендпоінти — все поміщалось на одному екрані:

Program.cs — початок
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products", () => "Products list");
app.MapGet("/products/{id}", (int id) => $"Product {id}");
app.MapPost("/products", () => "Created");

app.Run();

Через місяць — 15 ендпоінтів. Через квартал — 40. Program.cs розростається до 1000+ рядків. Ви витрачаєте 5 хвилин, щоб знайти потрібний ендпоінт. Колега одночасно редагує той самий файл — привіт, конфлікти злиття (merge conflicts) у Git.

Правило з практики: якщо Program.cs перевищує 100 рядків — це сигнал до рефакторингу. Код, який вимагає прокрутки більш ніж на 2-3 екрани, значно складніше підтримувати.

Як вирішити цю проблему? Існує послідовна еволюція підходів — кожен наступний крок додає більше структури:

КрокПідхідКоли використовувати
1Extension Methods5–15 ендпоінтів, малий проєкт
2Route Groups15–30 ендпоінтів, потрібні спільні префікси та фільтри
3Feature Folders30+ ендпоінтів, середній проєкт
4Vertical Slice ArchitectureВеликий проєкт, де потрібна повна ізоляція фіч

2. Крок 1: Extension Methods — виносимо ендпоінти

Ідея

Найпростіший спосіб розвантажити Program.csперенести групи ендпоінтів у окремі файли за допомогою методів розширення (Extension Methods). Кожна сутність (Products, Orders, Users) отримує свій файл.

Реалізація

Створюємо файл для ендпоінтів

Створіть папку Endpoints/ і файл ProductEndpoints.cs:

Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static WebApplication MapProductEndpoints(
        this WebApplication app)
    {
        app.MapGet("/products", GetAll);
        app.MapGet("/products/{id}", GetById);
        app.MapPost("/products", Create);
        app.MapPut("/products/{id}", Update);
        app.MapDelete("/products/{id}", Delete);

        return app;  // Для ланцюжкового виклику
    }

    private static IResult GetAll()
    {
        // Логіка отримання списку товарів
        return Results.Ok(new[] {
            new { Id = 1, Name = "Кава" },
            new { Id = 2, Name = "Чай" }
        });
    }

    private static IResult GetById(int id)
    {
        return Results.Ok(new { Id = id, Name = "Кава" });
    }

    private static IResult Create(ProductRequest req)
    {
        return Results.Created(
            $"/products/{1}", new { Id = 1, req.Name });
    }

    private static IResult Update(int id,
        ProductRequest req)
    {
        return Results.Ok(new { Id = id, req.Name });
    }

    private static IResult Delete(int id)
    {
        return Results.NoContent();
    }
}

record ProductRequest(string Name, decimal Price);

Підключаємо в Program.cs

Program.cs — чистий і лаконічний
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Кожна сутність — один рядок! ✨
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapUserEndpoints();

app.Run();

Структура проєкту

Ключовий прийом: замість анонімних лямбд (() => ...) ми використовуємо іменовані приватні методи (GetAll, GetById). Це полегшує читання, дебагінг та навігацію коду у IDE.

Повертаємо WebApplication з extension method — це дозволяє ланцюжковий виклик: app.MapProductEndpoints().MapOrderEndpoints(). Такий патерн називається fluent interface і широко використовується в ASP.NET Core.

3. Крок 2: Route Groups — спільні налаштування

Проблема

С Extension Methods ми розв'язали проблему великого Program.cs. Але з'являється нова: дублювання префіксів і конфігурації:

❌ Повторення "/products" у кожному рядку
app.MapGet("/products", GetAll);
app.MapGet("/products/{id}", GetById);
app.MapPost("/products", Create);
app.MapPut("/products/{id}", Update);
app.MapDelete("/products/{id}", Delete);
// Якщо потрібно перейменувати "/products"
// на "/catalog" — 5 правок 😩

А якщо ми хочемо додати авторизацію для всіх ендпоінтів товарів? Доведеться писати .RequireAuthorization() на кожному рядку.

Рішення: MapGroup

MapGroup (Групи маршрутів) — вбудований механізм ASP.NET Core для групування ендпоінтів зі спільним префіксом і конфігурацією:

Endpoints/ProductEndpoints.cs — з MapGroup
public static class ProductEndpoints
{
    public static WebApplication MapProductEndpoints(
        this WebApplication app)
    {
        // Створюємо групу з префіксом "/products"
        var group = app.MapGroup("/products")
            .WithTags("Products");  // для OpenAPI/Swagger

        group.MapGet("/", GetAll);
        group.MapGet("/{id}", GetById);
        group.MapPost("/", Create);
        group.MapPut("/{id}", Update);
        group.MapDelete("/{id}", Delete);

        return app;
    }

    private static IResult GetAll() =>
        Results.Ok(new[] { "Кава", "Чай" });

    private static IResult GetById(int id) =>
        Results.Ok(new { Id = id });

    private static IResult Create(ProductRequest req) =>
        Results.Created($"/products/1", req);

    private static IResult Update(
        int id, ProductRequest req) =>
        Results.Ok(new { Id = id, req.Name });

    private static IResult Delete(int id) =>
        Results.NoContent();
}

Тепер "/products" написано один раз. Якщо потрібно перейменувати — одна правка.

Версіонування через вкладені групи

Групи можна вкладати одна в одну. Це ідеально для версіонування API:

Program.cs — API версіонування
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Група верхнього рівня: /api/v1
var v1 = app.MapGroup("/api/v1");

v1.MapProductEndpoints();   // → /api/v1/products/...
v1.MapOrderEndpoints();     // → /api/v1/orders/...

// Нова версія: /api/v2
var v2 = app.MapGroup("/api/v2");

v2.MapProductEndpointsV2(); // → /api/v2/products/...

app.Run();
Endpoints/ProductEndpoints.cs — приймає RouteGroupBuilder
public static class ProductEndpoints
{
    // Тепер метод розширює RouteGroupBuilder, не WebApplication
    public static RouteGroupBuilder MapProductEndpoints(
        this RouteGroupBuilder group)
    {
        var products = group.MapGroup("/products")
            .WithTags("Products");

        products.MapGet("/", GetAll);
        products.MapGet("/{id}", GetById);

        return group;
    }

    // ...
}

Спільна конфігурація групи

Найпотужніша можливість MapGroup — застосування конфігурації до всіх ендпоінтів групи одразу:

Спільна конфігурація для групи
var admin = app.MapGroup("/admin")
    .RequireAuthorization("AdminPolicy") // Авторизація
    .AddEndpointFilter<LoggingFilter>()  // Логування
    .WithOpenApi();                      // OpenAPI-метадані

admin.MapGet("/users", GetUsers);       // Захищено!
admin.MapDelete("/users/{id}", DeleteUser); // Захищено!
// Не потрібно дублювати .RequireAuthorization()
// для кожного ендпоінту!

4. Крок 3: Feature Folders — організація за фічами

Проблема

Extension Methods та Route Groups вирішують проблему великого Program.cs, але при зростанні проєкту з'являється нова проблема — папка Endpoints/ росте безконтрольно:

MyApi/
├── Endpoints/
│   ├── ProductEndpoints.cs        ← 15 ендпоінтів
│   ├── OrderEndpoints.cs          ← 12 ендпоінтів
│   ├── UserEndpoints.cs           ← 8 ендпоінтів
│   ├── CartEndpoints.cs
│   ├── PaymentEndpoints.cs
│   └── ... (ще 10 файлів)
├── Models/
│   ├── Product.cs
│   ├── Order.cs
│   ├── User.cs
│   └── ... (ще 15 файлів)
├── Services/
│   ├── ProductService.cs
│   ├── OrderService.cs
│   └── ... (ще 10 файлів)
└── Program.cs

Ця структура називається організація за технічним типом (Technical Type Organization): усі ендпоінти — в одній папці, усі моделі — в іншій, усі сервіси — у третій. При роботі над фічою «Товари» ви стрибаєте між 3 папками (Endpoints/ProductEndpoints.csModels/Product.csServices/ProductService.cs).

Рішення: організація за фічами

У підході Feature Folders (організація за фічами) весь код, що стосується однієї сутності чи фічі, живе в одній папці:

Порівняння двох підходів

MyApi/
├── Endpoints/
│   ├── ProductEndpoints.cs
│   ├── OrderEndpoints.cs
│   └── UserEndpoints.cs
├── Models/
│   ├── Product.cs
│   ├── Order.cs
│   └── User.cs
├── Services/
│   ├── ProductService.cs
│   ├── OrderService.cs
│   └── UserService.cs
└── Program.cs

Мінуси:

  • Робота над фічою = стрибання між 3+ папками
  • 1 файл Git-конфлікт = блокує всю команду
  • Важко видалити фічу — код розмазаний по всій структурі
Практичне правило: організація за фічами стає вигідною, коли у проєкті більше 3 сутностей і більше одного розробника. Для невеликих проєктів Extension Methods + Route Groups цілком достатньо.

5. Крок 4: Vertical Slice Architecture

Проблема: горизонтальні шари

Усі попередні підходи все ще мають одну спільну рису: вони організують код або за технічним типом (Endpoints, Models, Services), або за сутністю (Products, Orders). Але навіть у Feature Folders ви маєте окремий ProductService, окремий ProductEndpoints, окремий ProductRequest. Зміна одного use case (наприклад, «Створити товар») вимагає правок у 3 файлах.

У класичній багатошаровій архітектурі (Layered Architecture) код організований горизонтально:

Loading diagram...
graph TD
    A["Presentation Layer<br/>(Endpoints / Controllers)"] --> B["Application Layer<br/>(Services / Use Cases)"]
    B --> C["Domain Layer<br/>(Entities / Business Rules)"]
    C --> D["Infrastructure Layer<br/>(Database / External APIs)"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#64748b,stroke:#334155,color:#ffffff
    style D fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Одна фіча (наприклад, «Створити товар») розрізана на 4 шари. Щоб зрозуміти, як працює створення товару, ви маєте відкрити 4 файли в 4 різних папках.

Вертикальний зріз: альтернативний підхід

Vertical Slice Architecture (VSA, «Архітектура вертикальних зрізів») — це підхід, запропонований Джиммі Богардом (Jimmy Bogard), автором бібліотеки MediatR. Ідея: замість горизонтальних шарів, які проходять через увесь додаток, ми розрізаємо додаток вертикально — кожен зріз (slice) — це повна реалізація одного use case від HTTP-запиту до бази даних.

Loading diagram...
graph LR
    subgraph "Традиційні шари"
        direction TB
        P["Presentation"]
        A["Application"]
        D["Domain"]
        I["Infrastructure"]
        P --> A --> D --> I
    end

    subgraph "Vertical Slices"
        direction LR
        S1["🟦 Create<br/>Product"]
        S2["🟧 Get<br/>Products"]
        S3["🟩 Delete<br/>Product"]
        S4["🟪 Create<br/>Order"]
    end

    style P fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style A fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#64748b,stroke:#334155,color:#ffffff
    style I fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style S1 fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style S2 fill:#f59e0b,stroke:#b45309,color:#ffffff
    style S3 fill:#10b981,stroke:#059669,color:#ffffff
    style S4 fill:#8b5cf6,stroke:#6d28d9,color:#ffffff

У вертикальному зрізі кожен use case — це самодостатня одиниця: endpoint, request/response DTO, валідація і логіка — все в одному файлі (або в одній маленькій папці).

Реалізація: один зріз = один файл

Кожна операція стає окремим статичним класом:

Features/Products/CreateProduct.cs
public static class CreateProduct
{
    // 1. Що приходить від клієнта
    public record Request(string Name, decimal Price);

    // 2. Що повертаємо клієнту
    public record Response(int Id, string Name,
        decimal Price);

    // 3. Маппінг ендпоінту
    public static void Map(RouteGroupBuilder group)
    {
        group.MapPost("/", Handler);
    }

    // 4. Вся логіка — тут
    private static IResult Handler(
        Request request, AppDbContext db)
    {
        // Валідація
        if (string.IsNullOrWhiteSpace(request.Name))
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    ["name"] = ["Name is required."]
                });

        if (request.Price < 0)
            return Results.ValidationProblem(
                new Dictionary<string, string[]>
                {
                    ["price"] = [
                        "Price cannot be negative."]
                });

        // Створення
        var product = new Product
        {
            Name = request.Name,
            Price = request.Price
        };

        db.Products.Add(product);
        db.SaveChanges();

        // Відповідь
        var response = new Response(
            product.Id, product.Name, product.Price);

        return Results.Created(
            $"/products/{product.Id}", response);
    }
}
Features/Products/GetProducts.cs
public static class GetProducts
{
    public record Response(int Id, string Name,
        decimal Price);

    public static void Map(RouteGroupBuilder group)
    {
        group.MapGet("/", Handler);
    }

    private static IResult Handler(AppDbContext db)
    {
        var products = db.Products
            .Select(p => new Response(
                p.Id, p.Name, p.Price))
            .ToList();

        return Results.Ok(products);
    }
}
Features/Products/GetProductById.cs
public static class GetProductById
{
    public record Response(int Id, string Name,
        decimal Price);

    public static void Map(RouteGroupBuilder group)
    {
        group.MapGet("/{id}", Handler);
    }

    private static IResult Handler(int id,
        AppDbContext db)
    {
        var product = db.Products.Find(id);
        if (product is null)
            return Results.NotFound();

        return Results.Ok(new Response(
            product.Id, product.Name, product.Price));
    }
}
Features/Products/DeleteProduct.cs
public static class DeleteProduct
{
    public static void Map(RouteGroupBuilder group)
    {
        group.MapDelete("/{id}", Handler);
    }

    private static IResult Handler(int id,
        AppDbContext db)
    {
        var product = db.Products.Find(id);
        if (product is null)
            return Results.NotFound();

        db.Products.Remove(product);
        db.SaveChanges();

        return Results.NoContent();
    }
}

Реєстрація у Program.cs

Program.cs — з VSA
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseInMemoryDatabase("Shop"));

var app = builder.Build();

var products = app.MapGroup("/api/v1/products")
    .WithTags("Products");

// Кожен зріз реєструє себе
CreateProduct.Map(products);
GetProducts.Map(products);
GetProductById.Map(products);
DeleteProduct.Map(products);

app.Run();

Структура проєкту з VSA

Переваги VSA

Висока когезія

Весь код для одного use case — в одному файлі. Відкрив CreateProduct.cs — побачив все: request, response, валідацію, бізнес-логіку, endpoint.

Ізоляція змін

Зміна логіки створення товару не зачіпає GetProducts або DeleteProduct. Ризик побічних ефектів (side effects) мінімальний.

CQRS безкоштовно

Кожен зріз — це або команда (Command: Create, Update, Delete), або запит (Query: Get). Це дає CQRS (Command Query Responsibility Segregation) без додаткових бібліотек.

Простота видалення

Видалити фічу = видалити один файл. Жоден інший файл не зламається. Порівняйте з багатошаровою архітектурою, де видалення Controller'а вимагає очищення Service, Repository, DTO.

Спільна логіка: що робити з дублюванням?

Одне з найчастіших запитань до VSA: «А якщо два зрізи мають спільну логіку?» Ось чотири стратегії:

Автоматична реєстрація зрізів

Замість ручної реєстрації кожного зрізу в Program.cs можна створити інтерфейс та зибрати всі реалізації через рефлексію:

IEndpoint — інтерфейс для автореєстрації
// Shared/IEndpoint.cs
public interface IEndpoint
{
    static abstract void Map(RouteGroupBuilder group);
}

// Features/Products/CreateProduct.cs
public static class CreateProduct : IEndpoint
{
    public record Request(string Name, decimal Price);

    public static void Map(RouteGroupBuilder group)
    {
        group.MapPost("/", Handler);
    }

    private static IResult Handler(Request req,
        AppDbContext db)
    {
        // ...
        return Results.Created("/products/1", req);
    }
}
Program.cs — автоматична реєстрація
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var products = app.MapGroup("/api/v1/products");

// Знаходимо всі класи, що реалізують IEndpoint
var endpointTypes = typeof(Program).Assembly
    .GetTypes()
    .Where(t => t.GetInterfaces().Any(
        i => i == typeof(IEndpoint)))
    .ToList();

foreach (var type in endpointTypes)
{
    // Викликаємо статичний метод Map
    var method = type.GetMethod("Map");
    method?.Invoke(null, [products]);
}

app.Run();
Автоматична реєстрація через рефлексію зручна, але має нюанси: потрібно фільтрувати зрізи за групами (Products vs Orders), щоб не зареєструвати все під одним префіксом. Для реальних проєктів розгляньте бібліотеку Carter, яка вирішує цю задачу елегантно.

6. Порівняння підходів: коли що обрати?

КритерійExtension MethodsRoute GroupsFeature FoldersVertical Slices
СкладністьМінімальнаНизькаСередняВище середнього
Ідеальний розмір5–15 ендпоінтів15–30 ендпоінтів30–60 ендпоінтів60+ ендпоінтів
Команда1–2 розробника2–3 розробника3–5 розробників5+ розробників
Зв'язність (coupling)СередняСередняНизькаМінімальна
Ізоляція змінНизькаСередняВисокаМаксимальна
Крива навчанняМінімальнаМінімальнаПотрібні конвенціїЗміна мислення
Loading diagram...
graph LR
    A["🟢 Program.cs<br/>1–5 endpoints"] -->|"Зростання"| B["🔵 Extension<br/>Methods"]
    B -->|"Потрібні<br/>спільні<br/>налаштування"| C["🟡 Route<br/>Groups"]
    C -->|"30+<br/>endpoints"| D["🟠 Feature<br/>Folders"]
    D -->|"Повна<br/>ізоляція"| E["🔴 Vertical<br/>Slices"]

    style A fill:#10b981,stroke:#059669,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#f97316,stroke:#c2410c,color:#ffffff
    style E fill:#ef4444,stroke:#b91c1c,color:#ffffff
Золоте правило еволюції: починайте з найпростішого підходу (Extension Methods) і ускладнюйте тільки коли відчуєте біль. Якщо навігація кодом і merge-конфлікти стають проблемою — переходьте на наступний рівень. Не використовуйте Vertical Slices для pet-проєкту з 5 ендпоінтами — це передчасна оптимізація архітектури.

7. Практичні завдання

Рівень 1: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


8. Резюме

Еволюція, не революція

Починайте з Extension Methods → Route Groups → Feature Folders → Vertical Slices. Кожен крок — відповідь на реальний біль, а не "на виріст".

Route Groups — потужність

MapGroup дає спільний префікс, авторизацію, фільтри та OpenAPI-теги для всіх ендпоінтів групи. Вкладені групи — для версіонування.

Feature Folders — cohesion

Весь код для фічі — в одній папці. Видалити фічу = видалити папку. Git-конфлікти — тільки всередині фічі.

VSA — максимальна ізоляція

Кожен use case — самодостатній файл із Request, Response, валідацією та логікою. CQRS безкоштовно. Зміна одного зрізу не зачіпає інші.
Copyright © 2026