Skip to Content
🚀 APSO is now in public beta. Get started →
GuidesSchemaRelationships

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 owner property (ManyToOne) and an ownerId foreign key column
  • On the User entity: a workspaces property (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" }
OptionDefaultDescription
joinTableNameAuto-generatedCustom name for the join table
joinColumnNameAuto-generatedForeign key column name for the “from” entity
inverseJoinColumnNameAuto-generatedForeign 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:

PropertyTypeRequiredDefaultDescription
fromstringYes—Source entity name
tostringYes—Target entity name
typestringYes—ManyToOne, OneToMany, OneToOne, or ManyToMany
to_namestringNoDerived from toCustom property name for the relationship on the “from” entity
nullablebooleanNofalseWhether the foreign key allows NULL
cascadeDeletebooleanNofalseCascade delete related records when the “from” entity is deleted
bi_directionalbooleanNofalseHint for bidirectional ManyToMany navigation
joinTableNamestringNoAutoCustom join table name (ManyToMany only)
joinColumnNamestringNoAutoCustom join column name (ManyToMany only)
inverseJoinColumnNamestringNoAutoCustom inverse join column name (ManyToMany only)

Foreign Key Naming

Apso generates foreign key columns following a consistent naming convention:

  • If to_name is set: {to_name}Id (e.g., to_name: "owner" produces ownerId)
  • If to_name is not set: {toLowerFirst(toEntityName)}Id (e.g., to: "Project" produces projectId)

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:

  1. No duplicate properties or foreign keys on any entity
  2. All relationships have the correct TypeORM decorators (@ManyToOne, @OneToMany, etc.)
  3. No circular import errors
  4. The tsc build succeeds without errors
  5. Each relationship is defined only once in the relationships array

For a clean regeneration when troubleshooting:

rm -rf src/autogen apso server scaffold

This removes all generated code and regenerates from scratch, eliminating any stale files from previous schema versions.

Complete Example

.apsorc
{ "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 } ] }

Next Steps

Last updated on