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 flowsResolver → Service → Repository → Schema; every admin feature is a self-contained module that flowscomponent → 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-module—AbstractBaseService,AbstractBaseRepository,BaseSchema,TransactionManager,ApBaseResolver.zync-nest-library—ApMailerModule,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 useforwardRef(). - Repositories extend
AbstractBaseRepository<Document>, overridebuildQuery()for$match(search, date range, ref, tenant scope), and usepage()/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 registermongoose-delete.
Base abstractions
BaseSchema— common fields incl. a publicref, company/branch scoping, and soft-delete. Tenant/company scoping is injected automatically incore/database/database.repository.tsby readingcontextSvc.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 ingql/query.tswrapping alluseMutation/useLazyQuery. context.tsxis the only consumer ofuse<Feature>Query(). It owns state and exposes plain async methods (xxxPage,createXxx,updateXxx,deleteXxx) + state viause<Feature>State(). After any mutation it triggersreload()/refetch.- Components import
use<Feature>State()only — they must not import fromgql/, call Apollo hooks, or touchfetch. If a component needs something new, the context is extended, never bypassed. - Forms: Formik + Yup, inputs wired through
Ap*components (which useuseField()internally).FormSchemalives in the component file. - State: React Context only. No Redux/Zustand/MobX.
- Auth: bearer token from
next-authinjected via ApollosetContext; 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 componentsPascalCase.tsx. - Classes:
PascalCase+ role suffix (BranchService,BranchRepository). - Enums:
PascalCasetype,UPPERCASE_SNAKEvalues. 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
TZenv) interprets them as-is — do not re-applystartOf/endOf('day')on backend (causes off-by-one for non-UTC tenants). Preferdayjsover newmomentusage. - Commits: Conventional Commits with scope (
feat(item): …,fix(invoice): …).