Backend Implementation Guide
Architectural Style
Use vertical modules and explicit use cases.
Avoid a generic repository layer that hides Drizzle's type inference and SQL composition.
Preferred:
routes -> service/use case -> Drizzle transaction/queryAvoid:
routes -> service -> generic repository -> DrizzleModule Structure
Recommended backend module:
apps/backend/src/modules/universal-entity-platform/
├── entities.routes.ts
├── entities.service.ts
├── entities.permissions.ts
├── entities.errors.ts
├── entities.mappers.ts
│
├── use-cases/
│ ├── create-entity.ts
│ ├── update-entity.ts
│ ├── move-entity.ts
│ ├── soft-delete-entity.ts
│ ├── restore-entity.ts
│ └── purge-entity.ts
│
├── hierarchy/
│ ├── get-children.ts
│ ├── get-descendants.ts
│ ├── get-ancestors.ts
│ ├── get-path.ts
│ └── validate-reparent.ts
│
├── properties/
│ ├── get-property-values.ts
│ ├── set-property-value.ts
│ └── validate-property-value.ts
│
├── tags/
│ ├── attach-tag.ts
│ └── detach-tag.ts
│
├── groups/
│ ├── create-group.ts
│ ├── add-group-member.ts
│ └── remove-group-member.ts
│
├── relationships/
│ ├── add-relationship.ts
│ ├── remove-relationship.ts
│ ├── get-incoming.ts
│ └── get-outgoing.ts
│
└── templates/
├── apply-template.ts
└── get-template-definition.tsService vs Use Case
A service coordinates related use cases.
A use case performs one meaningful action.
Example:
export class EntityService {
constructor(private readonly db: Db) {}
create(input: CreateEntityInput, actor: ActorContext) {
return createEntity({ db: this.db, input, actor });
}
move(input: MoveEntityInput, actor: ActorContext) {
return moveEntity({ db: this.db, input, actor });
}
}Actor Context
Pass actor context into use cases.
type ActorContext = {
userId: string;
rootEntityIds: string[];
capabilities: string[];
};Do not read user state globally inside use cases.
Transaction Pattern
Use transactions for multi-table writes.
Example:
await db.transaction(async (tx) => {
const entity = await createEntityRow(tx, input);
await setInitialProperties(tx, entity.id, input.properties);
await attachInitialTags(tx, entity.id, input.tagIds);
await writeAuditEvent(tx, entity.id, "entity.created", actor);
return entity;
});Create Entity Flow
1. Validate input contract.
2. Resolve entity type.
3. Resolve parent if provided.
4. Check actor can create under parent/root.
5. Validate parent can contain child type.
6. Generate id and path_label.
7. Compute path.
8. Insert entity.
9. Apply template defaults if provided.
10. Set property values.
11. Attach tags.
12. Emit audit event.
13. Return DTO.Move Entity Flow
1. Validate input.
2. Load target and new parent.
3. Check actor can move target.
4. Check actor can create under new parent.
5. Reject cycle.
6. Reject cross-root move unless privileged.
7. Validate type containment rule.
8. Update parent_entity_id.
9. Update target and descendant paths.
10. Emit audit event.
11. Return updated path.Soft Delete Flow
1. Validate target.
2. Check actor can delete target.
3. Load subtree ids.
4. Soft-delete relationships touching subtree.
5. Soft-delete tag assignments.
6. Soft-delete group memberships.
7. Soft-delete property values.
8. Soft-delete domain records.
9. Soft-delete entities.
10. Emit audit event.Restore Flow
1. Validate target exists and is deleted.
2. Check actor can restore target.
3. Ensure parent/root is active or included in restore.
4. Restore subtree records.
5. Restore properties, tags, memberships, relationships according to restore policy.
6. Emit audit event.Error Handling
Use typed application errors.
Example:
export class EntityOutOfScopeError extends Error {
code = "ENTITY_OUT_OF_SCOPE" as const;
}Map typed errors to HTTP responses at route boundary.
Recommended mapping:
400 INVALID_INPUT
403 CAPABILITY_DENIED / ENTITY_OUT_OF_SCOPE
404 ENTITY_NOT_FOUND
409 CYCLE_DETECTED / DUPLICATE_RELATIONSHIP
422 INVALID_PROPERTY_VALUEQuery Helpers, Not Generic Repositories
Use query helpers for complex, reusable Drizzle queries.
Example:
hierarchy/get-descendants.tsNot:
entity.repository.tsThis preserves Drizzle's native autocomplete and type inference.
Mappers
Mappers convert database rows to DTOs.
Keep mappers explicit.
Example:
function toEntityDto(row: EntityWithType): EntityDto {
return {
id: row.id,
parentEntityId: row.parentEntityId,
path: row.path,
entityType: {
id: row.entityType.id,
key: row.entityType.key,
name: row.entityType.name,
},
depth: row.path.split(".").length,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}Permission Checks
Call authorization utilities early in use cases.
Example:
await assertEntityInUserScope(tx, {
userId: actor.userId,
targetEntityId: input.entityId,
capability: "entity.update",
});For multi-entity operations, check every relevant entity.
Audit Events
Use cases should emit audit events for meaningful changes.
Examples:
entity.created
entity.updated
entity.moved
entity.deleted
entity.restored
property.updated
tag.attached
tag.detached
relationship.created
relationship.deleted
group.member_added
group.member_removedAvoid logging sensitive values.
Backend Invariants
The backend must enforce:
- entity type exists,
- parent exists for child creation,
- parent/path consistency,
- no hierarchy cycles,
- no normal containment in relationships,
- soft-delete filters,
- root entity scope,
- capability checks,
- property value validation,
- tag selection mode,
- group membership rules.