Summary
All ttd models use plain *_id: UUID columns and the service layer hand-rolls every join, cascade, and referential action. Ferro supports first-class relationships (ForeignKey(..., on_delete=...), BackRef(), Relation[...]) that would push this behavior into the schema, where the database enforces it. We should migrate the models to proper relationships + cascade definitions to simplify the service layer and remove a class of "forgot to clean up" bugs.
This is tech debt / refactor, not a feature. It should land as its own change, independent of feature work. Captured while designing the billable-expenses feature, where we deliberately stuck with the existing plain-id convention to avoid mixing two paradigms (see note at the bottom).
Current state: cascades and integrity are manual
Deletes are hand-rolled multi-level cascades:
services/clients.py:123-125 — deleting a client manually loops its projects, deletes each project's entries, deletes the projects, then the client (a two-level cascade by hand).
services/projects.py:134-135 — deleting a project manually deletes its entries first, then the project.
Invoice locking / referential nulling is manual:
services/invoicing.py:267 — persist_draft sets entry.invoice_id = invoice.id row by row to lock entries.
services/invoicing.py:320 — mark_invoice (void) loops linked entries and sets entry.invoice_id = None to release them (a hand-rolled SET NULL).
Joins are manual dict-building throughout the services (e.g. {pk(p): p for p in await Project.where(...)}), repeated across invoicing.py, clients.py, projects.py, reporting, etc.
What relationships would give us
- DB-enforced cascade —
Client → Project → Entry with on_delete="CASCADE" replaces the manual loops in clients.py / projects.py.
- DB-enforced SET NULL —
Entry.invoice / future expense lines with on_delete="SET NULL" replaces the manual unlinking in the void path.
- Relation navigation —
BackRef() / Relation[list[...]] replaces repeated manual dict joins (invoice.lines, client.projects, project.entries).
- Integrity — orphan rows become impossible at the DB level rather than by service discipline.
Caveat: invoice locking semantics need care
Voiding an invoice today is a deliberate SET NULL on entry.invoice_id (release the lock; the invoice row and its lines persist — numbers are never reused). Any migration must preserve that exact semantics: voiding releases entries without deleting the invoice. Naively making invoice lines CASCADE from the invoice while entries SET NULL needs to be modeled explicitly, and the void flow re-verified against the existing tests.
Suggested approach
- Map every current
*_id column to a ForeignKey/BackRef pair with the correct on_delete (CASCADE for owned children, SET NULL for invoice locks).
- Migrate one aggregate at a time (Client/Project/Entry first; Invoice/lines second given the locking nuance), keeping tests green at each step.
- Replace the manual dict joins with relation navigation as each model is converted.
- Delete the now-redundant manual cascade/unlink code in the services.
Note
This came up during the billable-expenses design. We chose to keep the new expense models (Expense, ExpenseReceipt, InvoiceExpenseLine) on the plain-id convention so they match existing code rather than introducing relationships piecemeal. When this migration happens, the expense models should be converted alongside the rest.
Summary
All
ttdmodels use plain*_id: UUIDcolumns and the service layer hand-rolls every join, cascade, and referential action. Ferro supports first-class relationships (ForeignKey(..., on_delete=...),BackRef(),Relation[...]) that would push this behavior into the schema, where the database enforces it. We should migrate the models to proper relationships + cascade definitions to simplify the service layer and remove a class of "forgot to clean up" bugs.This is tech debt / refactor, not a feature. It should land as its own change, independent of feature work. Captured while designing the billable-expenses feature, where we deliberately stuck with the existing plain-id convention to avoid mixing two paradigms (see note at the bottom).
Current state: cascades and integrity are manual
Deletes are hand-rolled multi-level cascades:
services/clients.py:123-125— deleting a client manually loops its projects, deletes each project's entries, deletes the projects, then the client (a two-level cascade by hand).services/projects.py:134-135— deleting a project manually deletes its entries first, then the project.Invoice locking / referential nulling is manual:
services/invoicing.py:267—persist_draftsetsentry.invoice_id = invoice.idrow by row to lock entries.services/invoicing.py:320—mark_invoice(void) loops linked entries and setsentry.invoice_id = Noneto release them (a hand-rolledSET NULL).Joins are manual dict-building throughout the services (e.g.
{pk(p): p for p in await Project.where(...)}), repeated acrossinvoicing.py,clients.py,projects.py, reporting, etc.What relationships would give us
Client→Project→Entrywithon_delete="CASCADE"replaces the manual loops inclients.py/projects.py.Entry.invoice/ future expense lines withon_delete="SET NULL"replaces the manual unlinking in the void path.BackRef()/Relation[list[...]]replaces repeated manual dict joins (invoice.lines,client.projects,project.entries).Caveat: invoice locking semantics need care
Voiding an invoice today is a deliberate
SET NULLonentry.invoice_id(release the lock; the invoice row and its lines persist — numbers are never reused). Any migration must preserve that exact semantics: voiding releases entries without deleting the invoice. Naively making invoice linesCASCADEfrom the invoice while entriesSET NULLneeds to be modeled explicitly, and the void flow re-verified against the existing tests.Suggested approach
*_idcolumn to aForeignKey/BackRefpair with the correcton_delete(CASCADEfor owned children,SET NULLfor invoice locks).Note
This came up during the billable-expenses design. We chose to keep the new expense models (
Expense,ExpenseReceipt,InvoiceExpenseLine) on the plain-id convention so they match existing code rather than introducing relationships piecemeal. When this migration happens, the expense models should be converted alongside the rest.