Skip to content

BaseServiceOperations: OneToMany Update

BaseServiceOperations.update() is the central method all services use to update TypeORM entities that have relations. It solves two critical TypeORM errors that appear as soon as you try to update a parent entity with OneToMany or ManyToMany children:

PostgreSQL ErrorSymptomRelation Type
23502null value in column "X_id" violates not-null constraintOneToMany
23505duplicate key value violates unique constraintManyToMany

The method runs entirely inside a single database transaction — any failure rolls back all operations atomically.


Both errors stem from how TypeORM’s preload() + save() handles relations:

preload() builds entity instances that include the existing junction records. When save() runs, TypeORM attempts to INSERT every junction row — including ones that already exist — triggering a unique constraint violation.

Fix: Strip M2M keys from the DTO before preload() and use addAndRemove() diff-sync instead.

preload() creates new child instances but never sets the inverse relation object (e.g., order). TypeORM cascades the INSERT with order_id = NULL.

Fix: Extract OneToMany from the DTO before preload(), then save via the child repository with the FK set explicitly.

preload() does not load existing children from the database. If the entity has cascade: true and you pass a partial child array, TypeORM detects missing children as “detached” and runs SET order_id = NULL on them.

Fix: Never assign children to the preloaded entity — save via childRepo directly, bypassing cascade entirely.


Orphans are OneToMany children that exist in the database for a given parent FK but are absent from the incoming DTO array.

DB state before update:
items: [item-A, item-B]
Incoming DTO:
items: [item-A] ← only item-A
Orphan: item-B (in DB, not in payload)
export class IUpdateOptions {
/**
* Permanently DELETE orphaned child rows.
* Use for value objects (gallery images, temp tags) that have no
* meaning without their parent and carry no audit significance.
* @default false
*/
hardDeleteOneToMany?: boolean = false;
/**
* Soft-delete orphaned child rows:
* sets is_deleted=true, deleted_at=now(), deleted_reason='delete with cascade'.
*
* Recommended for children with business value (order items, assignment logs),
* foreign keys from other tables, or audit/compliance requirements.
*
* @default true
*/
softDeleteOneToMany?: boolean = true;
constructor(partial?: Partial<IUpdateOptions>) {
if (partial) Object.assign(this, partial);
}
}

Priority rule: when both hardDeleteOneToMany and softDeleteOneToMany are true, hard delete wins.


The framework distinguishes two dimensions of intent:

Dimension 1 — Should orphans be processed at all?

Section titled “Dimension 1 — Should orphans be processed at all?”
Client sendsFramework interpretationOrphan handling
Key omitted{ name: 'Updated' }Partial update — don’t touch childrenNo orphan logic runs
Empty array{ items: [] }Explicit intent to clear all childrenApply orphan strategy
Array with items{ items: [A] }Desired final set; B is an orphanApply orphan strategy

This mirrors the standard HTTP PATCH contract: omitted = untouched, explicit empty = clear.

Dimension 2 — Hard Delete or Soft Delete?

Section titled “Dimension 2 — Hard Delete or Soft Delete?”
Use Hard Delete when…Use Soft Delete when…
Child is a value object with no independent meaningChild has business value (order items, logs)
No audit or history requirementsNeeded for audit trails or reporting
No FK references from other tablesOther tables reference the child
Examples: gallery images, temp tagsExamples: order line items, approval records

softDeleteOneToMany = true is the safe default — it preserves history while flagging data as removed.


ScenarioOptionsResult
Children key omittedanyNo orphan logic — children untouched
items: []defaultSoft-delete all existing children
items: []{ hardDeleteOneToMany: true }Hard-delete all existing children
items: [A], B in DBdefaultB soft-deleted
items: [A], B in DB{ hardDeleteOneToMany: true }B permanently deleted
items: [A], B in DB{ softDeleteOneToMany: false }B kept untouched

1. Partial Update — Children Key Omitted

Section titled “1. Partial Update — Children Key Omitted”
// Only scalar fields — all children completely untouched
await this.service.update(id, { name: 'Updated Name' }, currentUser);
// All existing items remain in DB unchanged
// Empty array = explicit intention to remove all
await this.service.update(id, { items: [] }, currentUser);
// All existing items: is_deleted=true, deleted_reason='delete with cascade'

3. Update Set — Soft-Delete Orphans (Default)

Section titled “3. Update Set — Soft-Delete Orphans (Default)”
// item-A updated, item-B becomes orphan → soft-deleted by default
await this.service.update(id, {
items: [{ id: 'item-A', is_primary: true }]
}, currentUser);
// Use when children are value objects with no business history
await this.service.update(id, {
items: [{ id: 'item-A', is_primary: true }]
}, currentUser, { hardDeleteOneToMany: true });
// Disable all orphan deletion
await this.service.update(id, {
items: [{ id: 'item-A', is_primary: true }]
}, currentUser, {
softDeleteOneToMany: false,
hardDeleteOneToMany: false,
});

The update() method proceeds in 8 ordered steps inside a single transaction.

Step 1 — Identify Relation Types from Metadata

Section titled “Step 1 — Identify Relation Types from Metadata”
const manyToManyRelations = metadata.relations.filter((r) => r.isManyToMany && r.isOwning);
const oneToManyRelations = metadata.relations.filter((r) => r.isOneToMany);

Step 2 — Extract ManyToMany from DTO (Before Preload)

Section titled “Step 2 — Extract ManyToMany from DTO (Before Preload)”
const manyToManyPayload: Record<string, Array<{ id: string }>> = {};
for (const rel of manyToManyRelations) {
if (rel.propertyName in dataRecord) {
manyToManyPayload[rel.propertyName] = dataRecord[rel.propertyName];
delete dataRecord[rel.propertyName]; // prevent preload from touching junction table
}
}

Step 3 — Extract OneToMany from DTO (Before Preload) + Track Explicit Keys

Section titled “Step 3 — Extract OneToMany from DTO (Before Preload) + Track Explicit Keys”
const explicitlySetOneToMany = new Set<string>(); // tracks keys sent by client, even if []
const oneToManyPayload: Record<string, unknown[]> = {};
for (const rel of oneToManyRelations) {
if (rel.propertyName in dataRecord) {
explicitlySetOneToMany.add(rel.propertyName); // key insight — omitted ≠ []
oneToManyPayload[rel.propertyName] = dataRecord[rel.propertyName];
delete dataRecord[rel.propertyName]; // prevent cascade
}
}

Step 4 — Preload Parent (Scalar Fields Only)

Section titled “Step 4 — Preload Parent (Scalar Fields Only)”
const preloadData = { id, ...dataRecord };
if (currentUser) preloadData.updated_by = userId;
const entityToUpdate = await txRepo.preload(preloadData);
if (!entityToUpdate) throw new NotFoundException(`Entity with id '${id}' not found.`);
// entityToUpdate.items === undefined → cascade will NOT trigger
const saved = await txRepo.save(entityToUpdate);
// Parent committed first so child FK references a real, committed row

Step 6 — Save OneToMany Children via Child Repo (Bypass Cascade)

Section titled “Step 6 — Save OneToMany Children via Child Repo (Bypass Cascade)”
for (const rel of oneToManyRelations) {
const incomingChildren = oneToManyPayload[rel.propertyName];
if (!Array.isArray(incomingChildren)) continue;
// Resolve FK column from TypeORM metadata
const fkDbName = rel.inverseRelation.joinColumns[0].databaseName;
const fkColumn = rel.inverseEntityMetadata.columns
.find((c) => c.databaseName === fkDbName);
const childRepo = transactionalManager.getRepository(rel.inverseEntityMetadata.target);
const children = incomingChildren.map((item) => {
const child = item as Record<string, unknown>;
child[fkColumn.propertyName] = id; // ← set FK explicitly
delete child[rel.inverseSidePropertyPath]; // ← remove relation object
return child;
// TypeORM: no id → INSERT ✅
// TypeORM: has id → UPDATE ✅
// Both have correct FK ✅
});
await childRepo.save(children);
// Only runs when the children key was EXPLICITLY sent (even if [])
if (explicitlySetOneToMany.has(rel.propertyName)) {
const resolvedOptions = new IUpdateOptions(options);
if (resolvedOptions.hardDeleteOneToMany || resolvedOptions.softDeleteOneToMany) {
const existingChildren = await childRepo.find({
where: { [fkColumn.propertyName]: id, is_deleted: false },
});
const incomingIds = new Set(
incomingChildren
.map((c) => (c as Record<string, unknown>).id as string | undefined)
.filter((cId): cId is string => cId !== undefined),
);
const orphanIds = existingChildren
.map((c) => String((c as Record<string, unknown>).id))
.filter((cId) => !incomingIds.has(cId));
if (orphanIds.length > 0) {
if (resolvedOptions.hardDeleteOneToMany) {
await childRepo.delete(orphanIds);
} else {
await childRepo.update(orphanIds, {
is_deleted: true,
deleted_at: new Date(),
deleted_reason: 'delete with cascade',
});
}
}
}
}
// Children key omitted → no-op (true PATCH semantics)
}

Step 7 — Sync ManyToMany via Diff-Based addAndRemove

Section titled “Step 7 — Sync ManyToMany via Diff-Based addAndRemove”
await this._syncManyToManyRelations(txRepo, id, manyToManyPayload);
// Loads current junction rows, diffs against payload, calls addAndRemove atomically

Step 8 — Reload with Updated Relations for Complete Response

Section titled “Step 8 — Reload with Updated Relations for Complete Response”
const updatedRelationNames = [
...Object.keys(oneToManyPayload),
...Object.keys(manyToManyPayload),
];
if (updatedRelationNames.length > 0) {
const reloaded = await txRepo.findOne({
where: { id, ...softDeleteFilter },
relations: updatedRelationNames,
});
return reloaded ?? saved;
}
return saved; // scalar-only update — no extra query needed
DTO containsExtra reload queryResponse
Scalar fields onlyNoParent fields only
OneToManyYes (1×)Parent + all O2M children
ManyToManyYes (1×)Parent + all M2M objects
BothYes (1×)Parent + all updated relations

ProblemRoot CauseSolution
23505 duplicate key (M2M)preload + save re-INSERTs existing junction rowsaddAndRemove() diff-based sync
23502 FK null (INSERT)preload does not set inverse relation on new childrenchildRepo.save() with explicit FK
23502 FK null (UPDATE)preload doesn’t know original children → TypeORM NULLs orphansBypass cascade entirely
Response missing relationspreload entity has no relations loadedfindOne({ relations }) after save