Backend Architecture
The CardNexus backend is built on Express with oRPC for contract-first API design, MongoDB with Mongoose for data persistence, and TSyringe for dependency injection. This page provides a high-level overview of how everything fits together.
Core Technologies
| Technology | Purpose |
|---|---|
| Express | HTTP server and routing |
| oRPC | Contract-first, type-safe API layer |
| MongoDB + Mongoose | Database and ODM |
| TSyringe | Dependency injection container |
| neverthrow | Type-safe error handling with Result pattern |
Layered Architecture
We follow a strict layered architecture to prevent circular dependencies and maintain clean separation of concerns. Each layer can only depend on layers below it.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LAYER 4: @repo/api (handlers) β
β Orchestrates domains, enriches data, maps errors to oRPC β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 3: Features (packages/backend/features/) β
β Cross-domain use cases: checkout, import-export, bulk-operations β
β β Can depend on: Domains, Views, Foundation β
β β NEVER depend on: Other Features β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 2: Domains β LAYER 2: Views β
β (packages/backend/domains/) β (packages/backend/views/ or β
β Business logic, single domain β packages/backend/domains/*-view) β
β β Can depend on: Views, β β Can depend on: Foundation ONLY β
β Foundation β β NEVER depend on: Domains β
β β NEVER depend on: Other β β
β Domains β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LAYER 1: Foundation β
β @repo/db, @repo/api-dtos, @repo/errors, @repo/logger, @repo/i18n β
β β External packages only β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββLayer Breakdown
Layer 4: Handlers (@repo/api)
- Implement oRPC contracts
- Orchestrate calls to multiple domains/features
- Enrich responses by combining data from multiple sources
- Map service errors to oRPC errors
Layer 3: Features (packages/backend/features/)
- Cross-domain use cases like checkout, import-export, bulk-operations
- Coordinate multiple domains for user-facing capabilities
- Can depend on Domains, Views, and Foundation
- Features must NOT depend on other Features
Layer 2: Domains (packages/backend/domains/)
- Business logic for a single bounded context
- Can depend on Views and Foundation only
- Domains must NOT import other Domains
Layer 2: Views (packages/backend/views/ or *-view packages)
- Read-only query builders and aggregations
- Can depend on Foundation only
- No business logic mutations allowed
Layer 1: Foundation
- Core shared packages:
@repo/db,@repo/api-dtos,@repo/errors,@repo/logger,@repo/i18n - Can only depend on external npm packages
Golden Rules
These rules are critical. Violations cause circular dependencies, tight coupling, and maintenance nightmares.
- Domains NEVER import other Domains - Cross-domain coordination belongs in Features or Handlers
- Views are read-only - No business logic mutations, just query builders
- Features orchestrate - They coordinate multiple domains for user-facing capabilities
- Handlers enrich - API handlers fetch from multiple services and assemble responses
- Domain services emit facts, not presentation - Emit events, donβt send emails directly
- Handlers NEVER use models/database directly - Always go through services
Example: Cross-Domain Orchestration
// β WRONG: Domain importing another domain
import { InventoryService } from '@repo/inventory'
@injectable()
class OrderService {
constructor(@inject(InventoryService) private inventory: InventoryService) {}
async createOrder() {
const snapshot = await this.inventory.getProductSnapshot() // VIOLATION!
}
}// β
CORRECT: Handler orchestrates multiple domains
// In @repo/api handler:
const inventoryService = container.resolve(InventoryService)
const orderService = container.resolve(OrderService)
const snapshot = await inventoryService.getProductSnapshot(items)
const orders = await orderService.createOrders({ ...data, snapshot })Common Patterns
Contract-First API (oRPC)
Contracts are defined in @repo/api-dtos, handlers implement them in @repo/api:
// 1. Define contract in @repo/api-dtos
export const myEndpointContract = oc
.input(MyInputSchema)
.output(MyOutputSchema)
.errors(myErrors)
// 2. Implement handler in @repo/api
export const myEndpointHandler = oc
.contract(myEndpointContract)
.handler(async ({ input, context, errors }) => {
// Implementation
})Error Handling (neverthrow)
Services return Result<T, E> instead of throwing exceptions:
async addItem(userId: string, itemId: string): Promise<Result<CartResponse, AddCartError>> {
if (!item) return err(CartErr.itemNotFound(itemId))
return ok(cartResponse)
}Dependency Injection (TSyringe)
Always inject actual class objects, not string tokens:
// β
CORRECT
@injectable()
class MyService {
constructor(@inject(InventoryService) private inventory: InventoryService) {}
}
// β WRONG - string tokens cause runtime errors
constructor(@inject("InventoryService") private inventory: InventoryService) {}Package Naming
All packages use the @repo/ prefix in the monorepo:
@repo/api- API handlers@repo/api-dtos- API contracts@repo/db- Database models@repo/inventory- Inventory domain@repo/order- Order domain
Where to Find Things
| What | Where |
|---|---|
| API Contracts | packages/core/api-dtos/src/lib/ |
| API Handlers | packages/backend/api/src/lib/orpc/handlers/ |
| Domain Services | packages/backend/domains/ |
| Features | packages/backend/features/ |
| Views | packages/backend/views/ or packages/backend/domains/*-view/ |
| Database Models | packages/core/db/src/mongo/ |
Next Steps
- See domain-specific documentation for detailed implementation guides
- Check the marketplace documentation for a real-world example of the architecture in action
- Review the API contracts in
@repo/api-dtosfor the complete API surface