Sari la conținut

Clean Architecture + module

Backend-ul ASYDATA respectă o Clean Architecture strictă combinată cu un sistem de module tip Odoo: fiecare modul de business este un assembly separat, activabil per tenant și extensibil fără a modifica codul existent. Această pagină descrie direcția dependențelor, regulile de cod și mecanica modulelor.

Dependențele merg într-o singură direcție. Domain nu cunoaște nimic; fiecare strat exterior cunoaște doar straturile dinspre interior.

Domain ◄── Application ◄── Platform ◄── Infrastructure ◄── WebApi
◄── Modules.* ◄───────────┘
StratProiectResponsabilitateCunoaște
DomainAsy.DomainEntități, value objects, domain events, Result<T>nimic
ApplicationAsy.ApplicationContracte de servicii (I…Service), DTO-uri, abstracțiuni (tenancy, auth)Domain
PlatformAsy.PlatformInfrastructura de module, tenancy, filtre de autorizareApplication
InfrastructureAsy.InfrastructureEF Core, persistență, mesagerie Kafka, auth, integrăriApplication, Platform
Modules.*src/Modules/Asy.Modules.*Module de business (servicii + endpoints)Application, Platform
WebApiAsy.WebApiCompoziție, pipeline HTTP, endpoints, OpenAPItoate

Domain nu cunoaște Application. Modules.* și Infrastructure se așază pe Application + Platform, dar nu se referențiază reciproc — de aceea filtrele partajate (ex. RequirePermission) trăiesc în Asy.Platform, accesibil din ambele.

RegulăDetaliu
NamingPrefix Asy. (NU Asydata.). Sub-foldere = numele bounded context-ului (Companies/, Banking/…)
Service pattern, NU CQRSICompaniesService are metode normale CreateAsync/UpdateAsync/…. Fără dispatcher, fără MediatR, fără Wolverine
Result<T> pentru eroriServiciile întorc Result<T> cu IsSuccess/Error; endpoint-urile mapează Error.Kind la status HTTP. Fără excepții pentru fluxuri de business
Build curatTreatWarningsAsErrors=true; analyzer-ele prea zeloase se suprimă punctual prin <NoWarn>, nu se dezactivează global
Strongly-typed IDsCompanyId, TenantId, UserId = readonly record struct(Guid Value), UUID v7 cu Guid.CreateVersion7()

Cerința de produs: un modul = unitate izolată, activabilă per tenant, customizabilă prin extindere fără a modifica codul modulului existent. Fiecare modul de business e un .csproj separat sub src/Modules/ care implementează IAsyModule (asydata-app-backend-dotnet/src/Asy.Platform/Modules/IAsyModule.cs).

Fiecare modul declară un Manifest și trei hook-uri de ciclu de viață:

MembruRol
ManifestKey, Label, IsCore, IsDefaultEnabled, SortOrder, Version, Dependencies
ConfigureServices(...)Înregistrare DI a serviciilor modulului (la build)
MapEndpoints(...)Endpoint-urile HTTP ale modulului (la runtime)
SeedAsync(...)Date inițiale, idempotent (la runtime)

Un modul IsCore nu poate fi dezactivat per tenant. Vezi CompaniesModule ca template: asydata-app-backend-dotnet/src/Modules/Asy.Modules.Companies/CompaniesModule.cs.

La startup, ModuleLoader.AddAsyModules(...) (asydata-app-backend-dotnet/src/Asy.Platform/Modules/ModuleLoader.cs):

  1. scanează assembly-urile date pentru tipuri ce implementează IAsyModule (constructor fără parametri);
  2. sortează topologic după Manifest.Dependencies (cu detectare de cicluri și de dependențe lipsă; tie-break pe SortOrder);
  3. apelează ConfigureServices pe fiecare modul, în ordine;
  4. înregistrează IModuleRegistry ca singleton.

După Build(): SeedAsyModulesAsync() rulează SeedAsync pe fiecare modul, apoi MapAsyModules() invocă MapEndpoints.

Modulele curente (mai 2026): companies (core), contacts, banking, clients, efactura, msign, tasks — vezi lista încărcată la startup în asydata-app-backend-dotnet/src/Asy.WebApi/Program.cs.

Un endpoint poate cere ca un modul să fie activ pentru tenantul curent:

routes.MapGet("/api/firms/{firmId}/clients", ListClientsAsync)
.RequireModule("clients");

Filtrul (asydata-app-backend-dotnet/src/Asy.Platform/Modules/RequireModuleFilter.cs) verifică modulele activate pentru tenantul rezolvat și întoarce 403 dacă modulul nu e activ (sau 401 dacă tenantul nu e rezolvat). Catalogul per-tenant va sta în platform_modules / company_modules; deocamdată e servit de InMemoryTenantDirectory (asydata-app-backend-dotnet/src/Asy.Infrastructure/Tenancy/InMemoryTenantDirectory.cs).

Pentru gating pe rol/acțiune (RBAC), există filtrul complementar RequirePermission("efactura", "view") (asydata-app-backend-dotnet/src/Asy.Platform/Authorization/RequirePermission.cs) — vezi Autentificare și autorizare și Actori și roluri.

Regula de aur: niciodată nu modifici codul unui modul existent ca să adaugi o customizare — adaugi un modul nou care se atașează. În ordinea cuplării:

PatternCând se foloseșteCuplaj
Domain events (Kafka)Cross-modul fără referințe directe (ex. OfferAccepted declanșează facturare)Minim
Keyed DI services (.NET 8+)Strategy-swap per tier (ex. pricing IT-Park vs Standard)Mediu
Decorator (Scrutor)Pre/post wrap (ex. audit log peste orice strategie)Strict
// Keyed DI — alegi strategia după config-ul tenantului
services.AddKeyedScoped<IOfferPricingStrategy, StandardPricingStrategy>("standard");
services.AddKeyedScoped<IOfferPricingStrategy, ItParkPricingStrategy>("it-park");
// Decorator — wrap fără înlocuire
services.Decorate<IOfferPricingStrategy, AuditLogPricingDecorator>();

Sunt plugin-uri în-binar: nu se evaluează cod customer la runtime, nu se încarcă DLL-uri dinamic de pe disc.

Echivalentul _inherit din Odoo pentru date, fără DDL per tenant:

  • field_definitions(company_id, entity_type, key, field_type, options, is_required, …) — registry de definiții;
  • coloane custom_fields jsonb pe entitățile core (companies, clients, tasks…);
  • validare la write printr-un CustomFieldsService;
  • frontend-ul randează formularele dinamic pe baza definițiilor.

Zero DDL per tenant, zero deploy per tenant — tot ce e custom trăiește în date.