Skip to content

Contracts and API Shape

Purpose

Contracts define the shared API surface between backend and frontend.

They should be pure TypeScript and Zod so they can be imported by both sides without pulling in database code.

Package Boundary

Recommended package:

text
packages/contracts

Allowed:

  • Zod schemas,
  • request DTOs,
  • response DTOs,
  • route contract definitions,
  • shared constants,
  • value type definitions,
  • capability constants.

Not allowed:

  • Drizzle schema,
  • database queries,
  • backend services,
  • encryption implementation,
  • environment variables,
  • Hono app instance.

Contract Directory Structure

text
packages/contracts/src/universal-entity-platform/
├── entity.contract.ts
├── entity-type.contract.ts
├── hierarchy.contract.ts
├── property.contract.ts
├── tag.contract.ts
├── group.contract.ts
├── relationship.contract.ts
├── template.contract.ts
├── authorization.contract.ts
└── index.ts

DTO Principles

DTOs are API shapes, not database rows.

Do not expose every database column by default.

Example:

ts
export const entityDtoSchema = z.object({
  id: z.string().uuid(),
  entityType: z.object({
    id: z.string().uuid(),
    key: z.string(),
    name: z.string(),
  }),
  parentEntityId: z.string().uuid().nullable(),
  path: z.string(),
  depth: z.number().int().nonnegative(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

export type EntityDto = z.infer<typeof entityDtoSchema>;

Input Schema Principles

Input schemas should reflect what users or clients are allowed to provide.

Do not accept fields that should be generated server-side.

Example:

ts
export const createEntityInputSchema = z.object({
  entityTypeKey: z.string().min(1),
  parentEntityId: z.string().uuid().nullable().optional(),
  templateKey: z.string().min(1).optional(),
  properties: z.record(z.string(), z.unknown()).optional(),
  tagIds: z.array(z.string().uuid()).optional(),
});

export type CreateEntityInput = z.infer<typeof createEntityInputSchema>;

Server generates:

text
id
path_label
path
created_at
updated_at

Entity Type Contracts

ts
export const entityTypeDefinitionDtoSchema = z.object({
  id: z.string().uuid(),
  key: z.string(),
  name: z.string(),
  description: z.string().nullable(),
  icon: z.string().nullable(),
  isSystem: z.boolean(),
});

export const createEntityTypeDefinitionInputSchema = z.object({
  key: z.string().regex(/^[a-z][a-z0-9_]*$/),
  name: z.string().min(1),
  description: z.string().optional(),
  icon: z.string().optional(),
});

Hierarchy Contracts

ts
export const entityPathNodeDtoSchema = z.object({
  id: z.string().uuid(),
  entityTypeKey: z.string(),
  displayName: z.string().nullable(),
  depth: z.number().int().nonnegative(),
});

export const moveEntityInputSchema = z.object({
  targetEntityId: z.string().uuid(),
  newParentEntityId: z.string().uuid(),
});

Property Contracts

ts
export const propertyValueTypeSchema = z.enum([
  "text",
  "long_text",
  "number",
  "integer",
  "boolean",
  "date",
  "datetime",
  "json",
  "ip_address",
  "mac_address",
  "url",
  "email",
  "secret_reference",
  "single_select",
  "multi_select",
  "entity_reference",
]);

export const propertyDefinitionDtoSchema = z.object({
  id: z.string().uuid(),
  key: z.string(),
  name: z.string(),
  valueType: propertyValueTypeSchema,
  isRequired: z.boolean(),
  isSensitive: z.boolean(),
  isSearchable: z.boolean(),
  validation: z.record(z.string(), z.unknown()),
});

Relationship Contracts

ts
export const createRelationshipInputSchema = z.object({
  sourceEntityId: z.string().uuid(),
  targetEntityId: z.string().uuid(),
  relationshipType: z.string().min(1),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

Group Contracts

ts
export const addGroupMemberInputSchema = z.object({
  groupEntityId: z.string().uuid(),
  memberEntityId: z.string().uuid(),
});

Error Shape

Use a consistent API error shape.

ts
export const apiErrorSchema = z.object({
  code: z.string(),
  message: z.string(),
  details: z.record(z.string(), z.unknown()).optional(),
});

Recommended error codes:

text
ENTITY_NOT_FOUND
ENTITY_TYPE_NOT_FOUND
ENTITY_OUT_OF_SCOPE
CAPABILITY_DENIED
INVALID_PARENT
CYCLE_DETECTED
INVALID_PROPERTY_VALUE
DUPLICATE_RELATIONSHIP
DUPLICATE_TAG_ASSIGNMENT
SOFT_DELETED_RECORD

Route Shape

Suggested routes:

text
GET    /entity-types
POST   /entity-types

GET    /entities/:entityId
POST   /entities
PATCH  /entities/:entityId
DELETE /entities/:entityId
POST   /entities/:entityId/restore

GET    /entities/:entityId/children
GET    /entities/:entityId/descendants
GET    /entities/:entityId/ancestors
GET    /entities/:entityId/path
POST   /entities/:entityId/move

GET    /entities/:entityId/properties
PUT    /entities/:entityId/properties/:propertyDefinitionId

POST   /entities/:entityId/tags/:tagEntityId
DELETE /entities/:entityId/tags/:tagEntityId

GET    /entities/:entityId/relationships
POST   /relationships
DELETE /relationships/:relationshipId

POST   /groups
POST   /groups/:groupEntityId/members/:memberEntityId
DELETE /groups/:groupEntityId/members/:memberEntityId

Contract Versioning

Start simple:

text
/contracts package version follows app version

Introduce explicit API versioning only when external clients require long-term compatibility.

Rule Of Thumb

When a frontend component needs a type, import it from contracts.

When backend code needs database columns, import from @eco-manager/db or your chosen DB package.

Do not import Drizzle schema into the frontend.