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:
packages/contractsAllowed:
- 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
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.tsDTO Principles
DTOs are API shapes, not database rows.
Do not expose every database column by default.
Example:
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:
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:
id
path_label
path
created_at
updated_atEntity Type Contracts
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
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
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
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
export const addGroupMemberInputSchema = z.object({
groupEntityId: z.string().uuid(),
memberEntityId: z.string().uuid(),
});Error Shape
Use a consistent API error shape.
export const apiErrorSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
});Recommended error codes:
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_RECORDRoute Shape
Suggested routes:
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/:memberEntityIdContract Versioning
Start simple:
/contracts package version follows app versionIntroduce 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.