Backend Architecture
Statusβ
Target architecture for the EUDIPLO backend. The migration is incremental: see the refactoring plan for what is done, the known debt, and the next slices. The plan is not an instruction to execute tasks automatically.
Apply these boundaries to new or explicitly migrated application/domain code. Existing services mix responsibilities; classify a component by its role rather than its *.service.ts suffix. Preserve the capability ownership and module rules in Backend Development.
Internal changes are permitted within the requested task, with all affected callers updated. Preserve public HTTP/protocol contracts, SDK/configuration formats, persisted data, and security behavior unless a behavior change is explicitly in scope. Architectural preference alone does not authorize an external breaking change.
Scope: where the layering appliesβ
Ports and adapters are a tool, not a goal. Apply them where they pay off, and keep everything else simple.
| Area | Expected shape | Why |
|---|---|---|
| Protocol and trust core: OID4VCI, OID4VP, authorization, credential formats, trust evaluation, sessions | Use cases in application/, rules in domain/, infrastructure behind ports/ | Security-critical branching logic that must be testable without HTTP or a database; real extension points (formats, claims providers, trust sources, KMS, storage). |
| Administrative CRUD: tenant, client, credential/issuance/presentation configuration, status-list configuration, registrar configuration, audit log, config import/export | A feature service with an injected TypeORM repository is fine | Read, validate and save have little logic. Extra layers add ceremony without making the code safer or easier to change. |
Rules for the CRUD shape: keep DTO validation in the controller, keep tenant scoping on every query, throw NotFoundError subclasses or Nest HTTP exceptions, and do not put protocol logic there. When a CRUD service starts to carry real business rules, or becomes a dependency of the protocol core, extract a port for what the core needs.
The migration is done when the protocol and trust core follows the target shape and the ratchet baseline contains no core-related legacy entries. Administrative CRUD does not need to be migrated.
Architectural styleβ
EUDIPLO is a modular monolith using pragmatic hexagonal / ports-and-adapters principles.
The architecture is capability-oriented rather than layer-folder-oriented.
Top-level capabilities may include:
issuer/
verifier/
session/
trust/
crypto/
registrar/
auth/
storage/
platform/
The important architectural property is dependency direction, not uniform folder naming.
Dependency directionβ
Inbound adapter β Application / use case β Domain
β
Outbound port
β
Infrastructure adapter
Arrows describe source dependencies, not runtime call order. The application owns outbound ports and may depend on domain types; adapters depend inward on those contracts. Domain rules remain independent of application orchestration, transport, persistence, and adapter implementations.
NestJS modules wire implementations to tokens. Minimal NestJS DI decorators may remain on application classes when direct construction with fake dependencies still works; domain code stays framework-independent. HTTP exceptions, lifecycle hooks, scheduling, and framework configuration belong outside the application core.
Vocabulary and placementβ
| Role | Responsibility and placement |
|---|---|
| Controller / inbound adapter | Parse transport input, invoke a use case, map results and errors; feature controller or protocol adapter. |
| Application service / use case | Coordinate one workflow through domain rules and ports; inside the owning capability. |
| Domain service / model | Express infrastructure-independent rules and state; inside the owning capability. |
| Port | Application-owned contract for a required capability; near its consumers, not in a global catch-all folder. |
| Adapter | Implement a port with persistence, transport, or an SDK; owned by the relevant capability. |
| Repository | Port exposing aggregate operations with explicit tenant scope and atomicity requirements. |
| Persistence entity | TypeORM-decorated database representation; confined to persistence and composition. |
| DTO | Inbound/outbound API shape with validation/Swagger metadata; map to application commands/results at the boundary. |
Use local application/, domain/, ports/, or adapters/ folders when they clarify an extracted boundary. Small features do not need empty layers or one class per method. Cross-capability consumers use explicit public contracts; avoid deep imports into another capability's implementation.
Feature folder shapeβ
This is the single reference for where backend code goes. A feature only creates the folders it needs.
feature/
βββ feature.module.ts # composition root: binds ports to adapters, typed settings
βββ feature.controller.ts # inbound adapter: DTO parsing, HTTP/protocol error mapping
βββ feature-settings.ts # typed capability settings + injection token
βββ dto/ # API shapes (validation, Swagger)
βββ entities/ # TypeORM entities (adapter role)
βββ application/ # use cases, application errors (checked)
βββ domain/ # models, rules, domain errors (checked)
βββ ports/ # outbound contracts + injection tokens (checked)
βββ adapters/ # TypeORM repositories, HTTP/SDK clients, schedulers (*.job.ts)
βββ feature.service.ts # legacy/mixed service still being migrated
- Errors. Application and domain code throw plain
Errorsubclasses. A missing resource extendsNotFoundErrorfromshared/domain/not-found-error.ts, whichAllExceptionsFiltermaps to 404. Any other application error is mapped explicitly by the controller or protocol service that calls the use case. - Wiring. A framework-free class with constructor dependencies must be registered with a
useFactoryprovider that lists itsinjecttokens. As a bare class provider without@Injectable(), Nest constructs it withundefineddependencies. - Request data. Services receive plain values, never the Express
Request. For audit metadata, controllers use the@AuditMeta()parameter decorator and pass anAuditLogRequestMeta. - Single use is a conditional update. Codes, nonces,
request_uris and presentation responses are consumed with one conditional write (for exampleSessionStore.updateIfUnconsumedorconsumeRequestUri) whose result decides who wins. Never read a flag and write it later: concurrent requests would both pass. - Adapters are not a parking place.
adapters/is only checked for HTTP exceptions and imports of controllers, modules and other capabilities' adapters, so it must only hold code that implements a port. Moving orchestration there hides it from the checks.
Reference implementationsβ
Copy the patterns from these migrated areas when starting a new slice:
| Pattern | Where to look |
|---|---|
| Repository port, TypeORM adapter, shared SQLite/PostgreSQL contract test | session/ports/session.repository.ts, session/adapters/typeorm-session.repository.ts, apps/backend/test/session/session-repository.contract.ts |
| Application service for lookups and atomic single-use updates | session/application/session-store.ts |
| Use case with events and metrics behind ports | session/application/change-session-state.ts |
| Protocol use cases with a transport-neutral error mapped in the controller | issuer/issuance/oid4vci/authorization/application/, domain/oauth-error.ts, authorize/authorize.controller.ts |
| Characterization tests written before refactoring | issuer/issuance/oid4vci/authorization/authorize/authorize.controller.spec.ts, verifier/oid4vp/presentation-verification.spec.ts |
| Format registry and adapters | issuer/configuration/credentials/ (issuance), verifier/presentations/domain/credential-verifier-format.ts and verifier/presentations/adapters/ (verification) |
| External metadata behind a port with cache and HTTP adapter | issuer/issuance/oid4vci/ports/authorization-server-metadata.ts, adapters/http-external-authorization-server-metadata-resolver.ts |
| DI wiring test for factory-registered classes | trust/trust-module-wiring.spec.ts, issuer/issuance/oid4vci/authorization/authorization-module-wiring.spec.ts |
| Administrative CRUD without extra layers | verifier/presentations/configuration/presentation-config.service.ts |
Inbound adaptersβ
Examples:
- REST controllers
- OID4VCI protocol endpoints
- OID4VP protocol endpoints
- administrative APIs
- future CLI/application entry points
Inbound adapters translate transport/protocol input into application commands and map application results/errors back to the transport/protocol.
Application layerβ
The application layer coordinates use cases.
Examples:
- creating credential offers
- processing credential requests
- issuing credentials
- handling deferred issuance
- processing credential notifications
- creating presentation requests
- validating presentation responses
- resolving credential claims
- evaluating trust
Application code should express business/protocol workflow, not infrastructure mechanics.
Domain logicβ
Domain logic contains rules and behaviour that do not require infrastructure.
Examples may include:
- state transitions
- credential-format-independent validation
- trust-policy decisions
- issuance policy decisions
- presentation policy decisions
Not every feature needs a separate rich domain model.
Outbound portsβ
Ports represent application dependencies on external capabilities.
Examples:
SessionRepository
CredentialClaimsProvider
CredentialNotificationPublisher
PresentationResultPublisher
FederationResolver
TrustListProvider
CredentialIssuerFormat
CredentialVerifierFormat
FileStorage
KmsAdapter
ClientsProvider
Ports should be domain-specific.
Avoid generic abstractions that simply mirror an infrastructure library.
Infrastructure adaptersβ
Adapters implement ports using concrete technologies.
Examples:
SessionRepository
ββ TypeOrmSessionRepository
FileStorage
ββ LocalFileStorage
ββ S3FileStorage
KmsAdapter
ββ DbKmsAdapter
ββ VaultKmsAdapter
ββ AwsKmsAdapter
ββ CscKmsAdapter
ββ HttpKmsAdapter
ββ Pkcs11KmsAdapter
CredentialClaimsProvider
ββ ConfiguredCredentialClaimsProvider
ββ WebhookRemoteCredentialClaims
ββ WebhookSessionCredentialClaims
FederationResolver
ββ OpenIdFederationResolver
NestJS responsibilityβ
NestJS remains the application framework and dependency injection container.
Nest modules should primarily act as composition roots.
Example:
{
provide: SESSION_REPOSITORY,
useClass: TypeOrmSessionRepository,
}
Application logic should not instantiate adapters directly.
Persistenceβ
TypeORM is an infrastructure detail.
Application-facing services and ports should not expose TypeORM-specific types.
Avoid exposing:
Repository<T>FindOptionsWhereDeepPartialQueryDeepPartialEntity
Use application-specific models and repository operations.
Credential formatsβ
Credential formats are a first-class extensibility boundary.
OID4VCI is a transport/orchestration protocol and must not own SD-JWT VC or mdoc implementation details.
OID4VP is likewise responsible for presentation orchestration rather than format-specific cryptographic verification.
Preferred architecture:
Issuance
β
CredentialIssuerFormat
βββββββ΄ββββββ
β β
SD-JWT VC mdoc
Verification
β
CredentialVerifierFormat
βββββββ΄ββββββ
β β
SD-JWT VC mdoc
Separate issuer and verifier interfaces are preferred if their responsibilities differ significantly.
Format-specific concernsβ
Keep format-specific behaviour inside the implementation where practical.
SD-JWT VC examples:
- disclosures
- disclosure frame
vct- JOSE handling
- SD-JWT-specific key binding
mdoc examples:
- document type
- namespaces
- issuer-signed items
- DeviceKeyInfo
- COSE algorithms
- mdoc-specific holder binding
Registryβ
Use a format registry to resolve a suitable format implementation.
Avoid format-specific switch or if statements distributed throughout OID4VCI and OID4VP.
Claim resolutionβ
Credential claims should be resolved through an application abstraction such as:
interface CredentialClaimsProvider {
resolveClaims(
request: CredentialClaimsRequest,
): Promise<CredentialClaimsResult>;
}
HTTP webhooks are one adapter (ConfiguredCredentialClaimsProvider with a webhook remote-claims adapter). It builds on the existing attribute-provider configuration and preserves configured claims, deferred results, validation, authentication, and outbound URL policy.
This allows future implementations such as:
- static/configured claims
- database-backed providers
- n8n/workflow integrations
- custom provider plugins
Trust architectureβ
Separate:
- trust policy/evaluation
- federation resolution
- trust-list retrieval
- X.509 validation
- network transport
- caching
Trust decisions should be testable without HTTP.
Errorsβ
Application/domain code should use application/domain errors.
Examples:
SessionNotFound
CredentialConfigurationNotFound
UnsupportedCredentialFormat
InvalidCredentialProof
CredentialVerificationFailedError
IncompletePresentationError
Inbound adapters translate these into protocol/transport errors. Not-found errors extend the shared NotFoundError base and are mapped to 404 centrally; see Feature folder shape.
Configurationβ
Configuration should be validated centrally and passed into application components as typed capability-specific settings.
Avoid injecting ConfigService throughout core application logic.
Testing strategyβ
Preferred layers:
E2E / conformance tests
β
integration tests
β
application/use-case tests
β
domain unit tests
Application tests should use fake ports.
Adapters with multiple implementations should share contract test suites for their common guarantees, with capability-specific cases where implementations differ. SD-JWT VC and mdoc must retain their distinct binding and disclosure semantics.
Add characterization and boundary tests with each migrated slice. Preserve tenant isolation, atomic offer consumption, replay/nonce/DPoP checks, cleanup of sensitive data, and SQLite/PostgreSQL semantics. Introduce application models, error mapping, typed settings, and DI wiring alongside the use case that needs them rather than postponing those dependencies.
Unit tests belong beside source as *.spec.ts; E2E tests use apps/backend/test/*.e2e-spec.ts. apps/backend/src/platform/module-boundaries.spec.ts enforces shared-code isolation, legacy directory placement, layer boundaries and the ratchet baseline. Its helpers and fixture tests live in apps/backend/test/architecture/.
Current boundary enforcementβ
The checks use the installed TypeScript compiler API and Vitest, without introducing another lint/dependency tool. The existing CI unit-test job executes them.
- Feature-local
application/,domain/, andports/directories identify migrated core code. Every production TypeScript file in those directories is checked automatically; tests are excluded. adapters/,infrastructure/, andentities/identify infrastructure.*.controller.tsand*.module.tsidentify inbound adapters and composition roots.- Core checks follow the transitive local import graph, including type-only imports, re-exports, dynamic imports, and TypeScript-resolved aliases /
.jsspecifiers. Unclassified helpers do not hide infrastructure dependencies from a migrated consumer. Computed imports in core code are rejected because the target cannot be checked. - Application code may import only
Inject,Injectable, andOptionalfrom@nestjs/common; domain and port code may not depend on NestJS. Core code may not depend on the forbidden persistence, HTTP, filesystem, cloud, or identity-provider packages listed in the checker, or on local adapters/controllers/modules. - Domain code must not depend on application orchestration or application ports. Ports may use domain models but not application implementation classes.
- Core code may not import validation, DTO or logging frameworks (
class-validator,class-transformer,nestjs-zod,nestjs-pino). Plain zod schemas are allowed indomain/; DTOs wrap them withcreateZodDto. - Controllers are checked for direct TypeORM dependencies and repository contracts/adapters named
*.repository.ts, including forwarded barrel exports. They should invoke application behavior instead.
Ratchet baselineβ
apps/backend/test/architecture/architecture-baseline.json lists existing debt per file and category, so it can only shrink:
- Legacy files (no role above; migrations and generated code excluded):
expresseverywhere. In the protocol core (issuer/issuance/,verifier/,trust/,session/) alsoconfig(@nestjs/config),typeorm(typeorm,@nestjs/typeorm) andhttp-exception(Nestβ¦Exceptionfrom@nestjs/common). Administrative CRUD outside these paths, and presentation configuration management inverifier/presentations/configuration/, may use them freely, see Scope. - Adapters:
http-exception,adapter->controller,adapter->module,adapter->other-capability-adapter(a capability is the first folder undersrc/). - Controllers:
controller->adapter.
The test fails when a file gains a category that is not in the baseline, and when the baseline lists a category the file no longer has. After removing debt, regenerate the file and commit it with the change:
UPDATE_ARCHITECTURE_BASELINE=1 pnpm --filter @eudiplo/backend test
Do not regenerate to accept new debt; move the dependency behind a port or into the controller/module instead.
What the checks do not coverβ
Legacy files are only checked for the ratchet categories above, so they can still mix orchestration with persistence; the refactoring plan lists the hotspots. The boundary checks also cannot prove runtime wiring or behavior, so keep adapter contract tests, DI wiring tests, and HTTP integration tests alongside them.
Use the enforced directories for newly extracted core code. Add any newly encountered infrastructure SDK to the package rules and test it with a fixture.
Architectural principleβ
The objective is not maximum abstraction.
The objective is that EUDIPLO's protocol orchestration and application behaviour remain stable when infrastructure changes, including:
- database
- HTTP framework
- KMS
- storage
- trust infrastructure
- credential format
- identity provider
- claims provider