Relationships
Relationships define how entities connect to each other. They are declared in the top-level relationships array of your .apsorc file, separate from the entities array. Apso generates the appropriate TypeORM decorators, foreign key columns, join tables, and inverse properties from these declarations.
The One-Side-Only Rule
This is the single most important rule for relationships in Apso. Only define one side of each relationship. Apso CLI automatically generates the inverse side. Defining both sides causes duplicate properties, TypeScript compilation errors, and entity conflicts.
// WRONG -- defining both sides creates conflicts
{ "from": "User", "to": "Workspace", "type": "OneToMany" },
{ "from": "Workspace", "to": "User", "type": "ManyToOne" }
// CORRECT -- define one side, Apso generates the inverse
{ "from": "Workspace", "to": "User", "type": "ManyToOne", "to_name": "owner" }When you define a ManyToOne from Workspace to User with to_name: "owner", Apso generates:
- On the Workspace entity: an
ownerproperty (ManyToOne) and anownerIdforeign key column - On the User entity: a
workspacesproperty (OneToMany) pointing back to Workspace
Relationship Types
Apso supports four relationship types: ManyToOne, OneToMany, OneToOne, and ManyToMany.
ManyToOne
The most common relationship type. The “from” entity holds a foreign key to the “to” entity. Use this when many records of one entity belong to a single record of another.
{ "from": "Task", "to": "Project", "type": "ManyToOne" }Generated on the Task entity:
@ManyToOne(() => Project, (project) => project.tasks)
@JoinColumn({ name: 'projectId' })
project: Project;
@Column({ type: 'integer' })
projectId: number;Auto-generated on the Project entity (inverse side):
@OneToMany(() => Task, (task) => task.project)
tasks: Task[];Custom property name with to_name
By default, the property name on the “from” entity is derived from the “to” entity name (lowercased). Use to_name to set a custom property name:
{ "from": "Application", "to": "User", "type": "ManyToOne", "to_name": "owner" }Generates owner and ownerId on the Application entity instead of user and userId:
@ManyToOne(() => User, (user) => user.applications)
@JoinColumn({ name: 'ownerId' })
owner: User;
@Column({ type: 'integer' })
ownerId: number;Nullable relationships
By default, ManyToOne relationships are required (NOT NULL). Use nullable to make them optional:
{ "from": "ApplicationService", "to": "InfrastructureStack", "type": "ManyToOne", "to_name": "networkStack", "nullable": true }Generates:
@ManyToOne(() => InfrastructureStack, { nullable: true })
@JoinColumn({ name: 'networkStackId' })
networkStack: InfrastructureStack;
@Column({ type: 'integer', nullable: true })
networkStackId: number;OneToMany
The inverse of ManyToOne. The “from” entity has many records of the “to” entity. While you can define this direction, it is functionally equivalent to defining a ManyToOne in the other direction — Apso generates both sides either way.
{ "from": "User", "to": "WorkspaceUser", "type": "OneToMany", "nullable": true }Generated on the User entity:
@OneToMany(() => WorkspaceUser, (workspaceUser) => workspaceUser.user)
workspaceUsers: WorkspaceUser[];Auto-generated on the WorkspaceUser entity (foreign key side):
@ManyToOne(() => User, (user) => user.workspaceUsers)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'integer' })
userId: number;Tip: For clarity, most developers prefer defining the ManyToOne direction (where the foreign key lives) rather than the OneToMany direction. Both produce the same result.
OneToOne
A one-to-one relationship where each record of one entity corresponds to exactly one record of another. The “from” entity holds the foreign key.
{ "from": "UserProfile", "to": "User", "type": "OneToOne" }Generated on UserProfile:
@OneToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'integer' })
userId: number;ManyToMany
A many-to-many relationship creates a join table between two entities. Neither entity holds a direct foreign key; instead, a separate join table stores the associations.
{ "from": "User", "to": "Role", "type": "ManyToMany", "to_name": "roles" }Generated on the User entity (owning side):
@ManyToMany(() => Role, (role) => role.users)
@JoinTable()
roles: Role[];Auto-generated on the Role entity (inverse side):
@ManyToMany(() => User, (user) => user.roles)
users: User[];Custom join table configuration
For ManyToMany relationships, you can customize the join table and column names:
{
"from": "User",
"to": "Role",
"type": "ManyToMany",
"to_name": "roles",
"joinTableName": "user_roles",
"joinColumnName": "user_id",
"inverseJoinColumnName": "role_id"
}| Option | Default | Description |
|---|---|---|
joinTableName | Auto-generated | Custom name for the join table |
joinColumnName | Auto-generated | Foreign key column name for the “from” entity |
inverseJoinColumnName | Auto-generated | Foreign key column name for the “to” entity |
Bidirectional flag
The bi_directional option indicates that both sides of a ManyToMany should be navigable. This is primarily a hint for code generation:
{
"from": "User",
"to": "Role",
"type": "ManyToMany",
"to_name": "roles",
"bi_directional": true
}Important: Do not define a ManyToMany relationship AND also create an explicit join entity for the same association. This results in duplicate join tables and properties. Choose one approach: either ManyToMany (simpler) or an explicit join entity with two ManyToOne relationships (more flexible — allows extra fields on the association).
Relationship Properties Reference
Every relationship object in the relationships array supports these properties:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
from | string | Yes | — | Source entity name |
to | string | Yes | — | Target entity name |
type | string | Yes | — | ManyToOne, OneToMany, OneToOne, or ManyToMany |
to_name | string | No | Derived from to | Custom property name for the relationship on the “from” entity |
nullable | boolean | No | false | Whether the foreign key allows NULL |
cascadeDelete | boolean | No | false | Cascade delete related records when the “from” entity is deleted |
bi_directional | boolean | No | false | Hint for bidirectional ManyToMany navigation |
joinTableName | string | No | Auto | Custom join table name (ManyToMany only) |
joinColumnName | string | No | Auto | Custom join column name (ManyToMany only) |
inverseJoinColumnName | string | No | Auto | Custom inverse join column name (ManyToMany only) |
Foreign Key Naming
Apso generates foreign key columns following a consistent naming convention:
- If
to_nameis set:{to_name}Id(e.g.,to_name: "owner"producesownerId) - If
to_nameis not set:{toLowerFirst(toEntityName)}Id(e.g.,to: "Project"producesprojectId)
Join column names match the foreign key column name.
Self-Referencing Relationships
Entities can have relationships to themselves. This is useful for hierarchical data like organizational trees, threaded comments, or dependency chains:
{
"from": "InfrastructureStack",
"to": "InfrastructureStack",
"type": "ManyToOne",
"to_name": "networkStack",
"nullable": true
}This generates an InfrastructureStack that can optionally reference another InfrastructureStack as its network stack:
@ManyToOne(() => InfrastructureStack, { nullable: true })
@JoinColumn({ name: 'networkStackId' })
networkStack: InfrastructureStack;
@Column({ type: 'integer', nullable: true })
networkStackId: number;Self-referencing relationships should almost always be nullable: true to allow root-level records that do not have a parent.
Multiple Relationships to the Same Entity
An entity can have multiple relationships to the same target entity. Use to_name to distinguish them:
{ "from": "ApplicationService", "to": "InfrastructureStack", "type": "ManyToOne", "to_name": "networkStack", "nullable": true },
{ "from": "ApplicationService", "to": "InfrastructureStack", "type": "ManyToOne", "to_name": "databaseStack", "nullable": true }This produces two separate foreign keys on ApplicationService:
@ManyToOne(() => InfrastructureStack, { nullable: true })
@JoinColumn({ name: 'networkStackId' })
networkStack: InfrastructureStack;
@Column({ type: 'integer', nullable: true })
networkStackId: number;
@ManyToOne(() => InfrastructureStack, { nullable: true })
@JoinColumn({ name: 'databaseStackId' })
databaseStack: InfrastructureStack;
@Column({ type: 'integer', nullable: true })
databaseStackId: number;Common Patterns
Multi-tenant architecture
Scope resources to a workspace and track ownership:
{ "from": "Project", "to": "Workspace", "type": "ManyToOne" },
{ "from": "Project", "to": "User", "type": "ManyToOne", "to_name": "createdBy" }Audit trails
Track who performed actions with optional user references:
{ "from": "AuditLog", "to": "User", "type": "ManyToOne", "to_name": "actor", "nullable": true },
{ "from": "AuditLog", "to": "Workspace", "type": "ManyToOne" }Join entity with extra fields
When a ManyToMany relationship needs additional data (like a role or status), model it as an explicit join entity with two ManyToOne relationships:
// Instead of: { "from": "User", "to": "Workspace", "type": "ManyToMany" }
// Use an explicit join entity:
{ "from": "WorkspaceUser", "to": "User", "type": "ManyToOne" },
{ "from": "WorkspaceUser", "to": "Workspace", "type": "ManyToOne" }With the WorkspaceUser entity carrying additional fields:
{
"name": "WorkspaceUser",
"created_at": true,
"fields": [
{ "name": "role", "type": "enum", "values": ["User", "Admin"], "default": "User" },
{ "name": "status", "type": "enum", "values": ["Active", "Invited", "Inactive"] }
]
}This is more flexible than a plain ManyToMany because you can store role, invitation status, join date, and other metadata on the association.
Deployment history
Track deployments with references to the deploying user and the deployed service:
{ "from": "Deployment", "to": "ApplicationService", "type": "ManyToOne" },
{ "from": "Deployment", "to": "User", "type": "ManyToOne", "to_name": "deployedBy", "nullable": true }Common Pitfalls
Duplicate identifier errors
Cause: Defining both sides of a relationship in the relationships array.
Solution: Remove one side. Only define the relationship once — Apso generates the inverse.
”Property does not exist” errors
Cause: Referencing an inverse property name that does not match the auto-generated name.
Solution: Check the generated entity code in src/autogen/ to see the actual property names. Use to_name to control the name if the default is not what you expect.
”Multiple properties with same name” errors
Cause: Complex or circular relationship chains producing name collisions, or duplicate relationship definitions.
Solution: Use to_name to give each relationship a unique property name. Verify that no two relationships produce the same foreign key column name on a single entity.
ManyToMany plus explicit join entity conflicts
Cause: Defining both a ManyToMany between two entities AND an explicit join entity with ManyToOne relationships for the same association.
Solution: Choose one approach. Use ManyToMany for simple associations. Use an explicit join entity when you need extra fields on the association.
Circular eager loading
Cause: Deeply nested or circular relationships where eager loading traverses the cycle endlessly.
Solution: Avoid unnecessary deep nesting in your model. Use lazy relations for circular references. The default generated code does not use eager loading.
Validation Checklist
After scaffolding, verify your relationships are correct:
- No duplicate properties or foreign keys on any entity
- All relationships have the correct TypeORM decorators (
@ManyToOne,@OneToMany, etc.) - No circular import errors
- The
tscbuild succeeds without errors - Each relationship is defined only once in the
relationshipsarray
For a clean regeneration when troubleshooting:
rm -rf src/autogen
apso server scaffoldThis removes all generated code and regenerates from scratch, eliminating any stale files from previous schema versions.
Complete Example
{
"version": 2,
"rootFolder": "src",
"entities": [
{
"name": "User",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "email", "type": "text", "length": 255, "unique": true, "is_email": true },
{ "name": "fullName", "type": "text", "nullable": true }
]
},
{
"name": "Workspace",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "name", "type": "text" }
]
},
{
"name": "WorkspaceUser",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "role", "type": "enum", "values": ["User", "Admin"], "default": "Admin" },
{ "name": "status", "type": "enum", "values": ["Active", "Invited", "Inactive"] }
]
},
{
"name": "Application",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "name", "type": "text" },
{ "name": "status", "type": "enum", "values": ["Active", "Deleted"] }
]
},
{
"name": "ApplicationService",
"created_at": true,
"updated_at": true,
"fields": [
{ "name": "name", "type": "text" },
{ "name": "runtime", "type": "enum", "values": ["Node18", "Node20", "Python3"] }
]
},
{
"name": "InfrastructureStack",
"created_at": true,
"fields": [
{ "name": "stackType", "type": "enum", "values": ["Network", "Database", "Compute"] },
{ "name": "status", "type": "enum", "values": ["Provisioning", "Active", "Failed", "Deleted"] }
]
}
],
"relationships": [
{ "from": "WorkspaceUser", "to": "User", "type": "ManyToOne" },
{ "from": "WorkspaceUser", "to": "Workspace", "type": "ManyToOne" },
{ "from": "Workspace", "to": "Application", "type": "OneToMany" },
{ "from": "Application", "to": "User", "type": "ManyToOne", "to_name": "owner" },
{ "from": "Application", "to": "ApplicationService", "type": "OneToMany" },
{ "from": "ApplicationService", "to": "InfrastructureStack", "type": "ManyToOne", "to_name": "networkStack", "nullable": true },
{ "from": "ApplicationService", "to": "InfrastructureStack", "type": "ManyToOne", "to_name": "databaseStack", "nullable": true },
{ "from": "InfrastructureStack", "to": "InfrastructureStack", "type": "ManyToOne", "to_name": "networkStack", "nullable": true }
]
}