Skip to content

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:

text
routes -> service/use case -> Drizzle transaction/query

Avoid:

text
routes -> service -> generic repository -> Drizzle

Module Structure

Recommended backend module:

text
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.ts

Service vs Use Case

A service coordinates related use cases.

A use case performs one meaningful action.

Example:

ts
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.

ts
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:

ts
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

text
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

text
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

text
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

text
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:

ts
export class EntityOutOfScopeError extends Error {
  code = "ENTITY_OUT_OF_SCOPE" as const;
}

Map typed errors to HTTP responses at route boundary.

Recommended mapping:

text
400 INVALID_INPUT
403 CAPABILITY_DENIED / ENTITY_OUT_OF_SCOPE
404 ENTITY_NOT_FOUND
409 CYCLE_DETECTED / DUPLICATE_RELATIONSHIP
422 INVALID_PROPERTY_VALUE

Query Helpers, Not Generic Repositories

Use query helpers for complex, reusable Drizzle queries.

Example:

text
hierarchy/get-descendants.ts

Not:

text
entity.repository.ts

This preserves Drizzle's native autocomplete and type inference.

Mappers

Mappers convert database rows to DTOs.

Keep mappers explicit.

Example:

ts
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:

ts
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:

text
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_removed

Avoid 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.