Architecture Overview
Modular monolith, clean layering
One deployable backend, internal modules, deployed once per client. Not microservices.
Api and Workers are the two composition roots (highlighted) — they're the only projects that wire DI and reference Infrastructure.
Dependency rules (do not violate)
- Domain has zero project references and zero infrastructure packages. Pure models + domain logic.
- Application references only Domain. Holds use-cases, DTOs, validators, and interfaces.
- Infrastructure references Domain + Application. Holds EF Core, repositories, external integrations.
- Api and Workers are the only composition roots — they wire DI and reference Infrastructure.
- Controllers and jobs depend on Application abstractions, never on EF entities directly.
Domain is intentionally thin — Application is the effective domain layer
Logiswift.Eos.Domain currently contains a single file, Common/EntityBase.cs — a
marker base type, nothing else. All business logic, DTOs, and validation live in
Application (Application/Modules/<Module>/{Dto,Services,Validators}) instead. This
is a deliberate choice, not an oversight left over from scaffolding the project:
- Eos is database-first, legacy-stored-procedure-driven — most write paths are a
thin
SqlCommandcall into an existingMAS_Man*/WMS_Man*procedure that already encodes the real business rules on the legacy side. There's little independent domain logic left for aDomainlayer to own; the DTOs and services inApplication(parameter shape, validation, territory gating, result-code handling) are the domain layer's job for this project, just located one layer over from where a greenfield Clean Architecture project would put it. - Splitting that same logic across
Domain(entities/invariants) andApplication(orchestration) would add a layer of ceremony with no corresponding payoff here — there's no rich, independent business-rule model to protect behind it.
:::tip Revisit only if a real bounded context emerges
Revisit this only if a bounded context with genuinely independent business rules
needs to be extracted later — e.g. a module whose logic stops being "call the
legacy SP and shape the result" and starts being real invariant-carrying domain
logic that's reused across multiple Application use-cases. Until then, keep adding
new business logic to Application, not Domain, and don't treat Domain's
emptiness as something to "fill in."
:::
Modules
Operations, Rf, Reporting, Edi, Integration. Each spans Domain (models) and Application (use-cases). Keep module boundaries clean — a module could later be extracted, so avoid cross-module coupling except through Application interfaces.
Within Application/Modules/<Module>/, split by artifact type in subfolders —
Dto/ and Services/ (e.g. Application/Modules/Mas/Dto/BranchDto.cs,
Application/Modules/Mas/Services/BranchService.cs) — rather than a flat folder mixing
both. This keeps the module-first grouping (everything for one module still lives under
one root, so extracting it later is still "move this one folder") while keeping each
module's folder browsable as its DTO/service count grows. Interfaces stay the one
exception, in the shared Application/Common/Interfaces/ — they're the cross-cutting
contract Infrastructure needs to implement, not a module-internal detail.
Controllers are grouped by consumer: Controllers/Operations, Controllers/Rf
(lean payloads for handheld scanners), Controllers/Reporting, Controllers/External
(third-party + EDI). The frontends themselves (Eos.Web, Eos.Rf, Eos.Reporting) are
separate apps with their own tooling and pipelines — see the Repository layout section
in Getting Started — never added to backend/Logiswift.Eos.slnx.
Use injected ILogger with structured properties; log at Information for normal
operations, Warning for handled failures, Error for unhandled. Never log secrets or
PII. Always include correlation context.
API versioning — URL path segment, major version only
Every Operations/Rf/Reporting/External controller is versioned via a URL path segment:
api/v{major}/operations/... (e.g. api/v1/operations/cities). Decided and put in
place while Eos.Web is still the only consumer, specifically so Eos.Rf/
Eos.Reporting don't force a retrofit later.
:::note AuthController is the one deliberate exception
AuthController (api/auth/...) is cross-cutting, not consumer-scoped like
Operations/Rf/Reporting/External, so it stays unversioned. Weigh any future top-level,
non-consumer-scoped controller the same way before adding it to a versioned group.
:::
- Library:
Asp.Versioning.Mvc+Asp.Versioning.Mvc.ApiExplorer— not the deprecatedMicrosoft.AspNetCore.Mvc.Versioningpackage. - Reader:
UrlSegmentApiVersionReaderonly — no header or query-string reader. The version is a path segment, not metadata on the request. - Route attribute carries the version, not the controller class name —
[ApiVersion("1.0")]+[Route("api/v{version:apiVersion}/operations/[controller]")], neverOperationsV1Controller. Every versioned controller needs both attributes. AddApiVersioning(Program.cs):DefaultApiVersion = 1.0,AssumeDefaultVersionWhenUnspecified = true,ReportApiVersions = true. Paired withAddApiExplorer(GroupNameFormat = "'v'VVV", SubstituteApiVersionInUrl = true)so Swagger resolves the{version:apiVersion}route token correctly.
:::caution Migration shim — temporary, remove once confirmed unused
A small middleware in Program.cs (right after UseExceptionHandler) 308-redirects
any request still hitting the old unversioned api/operations/... path to the
equivalent api/v1/operations/... path (308, not 301/302, so POST/PUT/DELETE bodies
and methods survive the redirect). AssumeDefaultVersionWhenUnspecified exists for
the same reason — together they mean nothing 404s outright during the migration
window. Once every caller (Eos.Web today; Eos.Rf/Eos.Reporting later) is confirmed on
explicit /v1/ URLs — no traffic hitting the redirect shim — delete both the
middleware and the AssumeDefaultVersionWhenUnspecified/shim comments referencing it.
:::
- No multi-version support built yet — this is v1-only scaffolding. Don't add a
[ApiVersion("2.0")]controller, version-specific DI, or branching logic until there's an actual reason to ship a breaking v2 of something. - Frontend: every
src/api/*.tscall site uses the literalapi/v1/operations/...path — there's no shared base-path constant for this yet (each file hardcodes its own full path per call, matching the existing per-file convention). When adding a new endpoint, match that convention rather than introducing a new one.
Reference files: Program.cs (AddApiVersioning/AddApiExplorer/the redirect
middleware), any Operations controller (e.g. CitiesController.cs) for the
[ApiVersion]/[Route] pair, AuthController.cs for the unversioned exception.