Skip to main content

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 SqlCommand call into an existing MAS_Man*/WMS_Man* procedure that already encodes the real business rules on the legacy side. There's little independent domain logic left for a Domain layer to own; the DTOs and services in Application (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) and Application (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 deprecated Microsoft.AspNetCore.Mvc.Versioning package.
  • Reader: UrlSegmentApiVersionReader only — 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]")], never OperationsV1Controller. Every versioned controller needs both attributes.
  • AddApiVersioning (Program.cs): DefaultApiVersion = 1.0, AssumeDefaultVersionWhenUnspecified = true, ReportApiVersions = true. Paired with AddApiExplorer(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/*.ts call site uses the literal api/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.