Architecture — layering, base abstractions, stack

The whole system reduces to two per-tenant processes talking over GraphQL:

zerp-admin (Next.js) → GraphQL → zerp-be (NestJS) → MongoDB. One backend + one admin per tenant, each tenant fully isolated in its own database. Every backend feature is a self-contained module that flows Resolver → Service → Repository → Schema; every admin feature is a self-contained module that flows component → context → gql → Apollo.

Source: zerp-be/CLAUDE.md, zerp-admin/CLAUDE.md, zerp-be/src/, zerp-admin/src/. See multi-tenancy for the tenant/process/DB model and system overview for the big picture.


1. The two applications

App Tech Role
zerp-be NestJS 9, TypeScript, Apollo GraphQL (code-first), MongoDB via Mongoose 6 + mongoose-delete, Redis (ioredis), JWT (@nestjs/jwt + passport-jwt), class-validator/class-transformer, Jest 28 The API + business logic + data. One process per tenant.
zerp-admin Next.js 13, TypeScript (strict), Apollo Client 3, next-auth, Formik 2 + Yup 1, Ant Design 5 (always wrapped in Ap*), Tailwind 3 + SCSS globals The operator dashboard. One process per tenant, pointed at that tenant's backend.

Custom shared libraries used by the backend:

  • zync-nest-data-moduleAbstractBaseService, AbstractBaseRepository, BaseSchema, TransactionManager, ApBaseResolver.
  • zync-nest-libraryApMailerModule, ApUploadModule (S3), base utilities.

2. Backend layering (strict, non-negotiable)

Resolver  →  Service  →  Repository  →  Schema (Mongoose model)  →  MongoDB
  • No business logic in resolvers. Resolvers extend ApBaseResolver<T>, expose @Query/@Mutation/@ResolveField, and apply @ApGqlAuthorize() (auth + permission) and @AuditMeta() (audit) — see permissions-access and audit-trail.
  • No raw Mongoose queries outside repositories. Services extend AbstractBaseService<T>, inject the matching repository + TransactionManager, and contain all domain logic. Cross-module circular deps use forwardRef().
  • Repositories extend AbstractBaseRepository<Document>, override buildQuery() for $match (search, date range, ref, tenant scope), and use page() / handlePageFacet() / handlePageResult() for aggregation pagination. Repositories never import services (one-way dependency).
  • Schemas extend BaseSchema (provides _id, ref, createdAt, updatedAt, companyId, branchId, soft-delete fields) and register mongoose-delete.

Base abstractions

  • BaseSchema — common fields incl. a public ref, company/branch scoping, and soft-delete. Tenant/company scoping is injected automatically in core/database/database.repository.ts by reading contextSvc.companyId (branch read-filtering exists but is currently dormant — see company-branch).
  • TransactionManager — wraps multi-collection writes in one Mongo session. Every write that touches more than one collection (e.g. an order writing order + items + stock + GL legs) runs inside a session.
  • DTO composition (canonical shape, hand-written CommonInput):
    @InputType() class CommonInput { /* shared fields */ }
    class CreateXInput extends CommonInput {}
    class UpdateXInput extends PartialType(CommonInput) { @Field() _id: string }
    class QueryXInput  extends PartialType(CommonInput) { keyword?; fromDate?; toDate? }
    // XPageInput / XPageResult for pagination

Code-first GraphQL

schema.gql is generated at boot by the code-first driver — never hand-edited. It is the authoritative API contract the admin types itself against. Per-request tenant context is populated by ApContextMiddleware into ApContextService.


3. Admin layering (strict)

component  →  <Feature>Context  →  gql/query.ts  →  Apollo Client  →  (GraphQL) backend
component  →  use<Feature>State()   (read state only)
  • Every module exposes a single use<Feature>Query() hook in gql/query.ts wrapping all useMutation/useLazyQuery.
  • context.tsx is the only consumer of use<Feature>Query(). It owns state and exposes plain async methods (xxxPage, createXxx, updateXxx, deleteXxx) + state via use<Feature>State(). After any mutation it triggers reload()/refetch.
  • Components import use<Feature>State() only — they must not import from gql/, call Apollo hooks, or touch fetch. If a component needs something new, the context is extended, never bypassed.
  • Forms: Formik + Yup, inputs wired through Ap* components (which use useField() internally). FormSchema lives in the component file.
  • State: React Context only. No Redux/Zustand/MobX.
  • Auth: bearer token from next-auth injected via Apollo setContext; on 401 the Apollo error link retries once then signs out (see auth).

4. Module layout

Backend (src/modules/<feat>/): <feat>.module.ts, .resolver.ts, .service.ts, .repository.ts, .schema.ts, .dto.ts, optional .controller.ts (REST), decorators/, guards/, index.ts. Heavy domains nest sub-modules (e.g. finance/journal, hr/payroll, inventory/stock).

Admin (src/modules/<feat>/): components/, gql/{query,fragment}.ts, context.tsx, model.ts, page.tsx, detail.tsx, nested <subfeat>/. Thin route wrappers live under src/pages/.

Shared backend infra lives in src/core/ (database glue, middlewares, plugins, pubsub, directives), src/interceptors/ (gql-request, audit, validate-request), src/migrations/ (idempotent one-off scripts run from the master control plane).


5. Cross-cutting concerns (the platform spine)

Concern Doc
Tenant isolation, per-tenant DBs, context, timezone multi-tenancy
Login, JWT, OTP, ESS login, company/module selection auth
RBAC: permission modules/actions, access groups, CASL, master-access permissions-access
Generic approval workflow engine workflow-approval-engine
Audit trail + system log (@AuditMeta) audit-trail
Notifications (in-app, Redis subscriptions, email) notifications
File upload + S3 storage files-assets-upload
Financial statements + operational reports reporting-framework

6. Conventions

  • Files: kebab-case.ts, dot-segmented by role (branch.service.ts). Admin components PascalCase.tsx.
  • Classes: PascalCase + role suffix (BranchService, BranchRepository).
  • Enums: PascalCase type, UPPERCASE_SNAKE values. GraphQL ops in admin: SCREAMING_SNAKE.
  • Interfaces (admin): I-prefixed. Shared UI: Ap-prefixed.
  • Module-first: a feature is self-contained; never split a feature by technical layer across the tree.
  • Multi-tenant: always read contextSvc.companyId/branchId; never hardcode tenant/company/branch IDs.
  • Dates: frontend sends pre-aligned millisecond timestamps; the backend (with TZ env) interprets them as-is — do not re-apply startOf/endOf('day') on backend (causes off-by-one for non-UTC tenants). Prefer dayjs over new moment usage.
  • Commits: Conventional Commits with scope (feat(item): …, fix(invoice): …).