Good fences: the academic catalog, and the import boundary that broke once a module used it
- #django
- #django-ninja
- #postgresql
- #row-level-security
- #multi-tenancy
- #import-linter
- #modular-monolith
- #react
- #tdd
- #building-in-public
I’m building Turmarium in public: a multi-tenant B2B SaaS for schools. A school signs up as a tenant, registers its people (admins, staff, teachers, students) and its academic structure, and runs a real term on it.
Two updates went to tenant isolation, because it’s the one thing here you can’t retrofit. Update 1 proved it at the database with Postgres row-level security. Update 2 switched it on for real traffic and shipped the first tenant-data endpoints.
This update finally builds a feature: the academic catalog. A discipline is a subject a school teaches (Math, unique per school by its code). A syllabus is the ordered list of units under a discipline. A term is a school period, with exactly one marked current at a time.
Four new tables, each extending the same TenantScoped base from update 1, each with forced row-level security using the policy form update 2 landed on: compare organization_id::text against the per-request session variable, and never cast that variable to an integer, or the Postgres planner folds the cast and crashes on the reset value. Same isolation drill as before, four more times. Boring on purpose, exactly what you want from a security backstop.
The module that wires itself in
Update 1’s one rule: the academic core is the spine, and every other capability is a module that reads the core through a published seam and never imports its internals. The core does not know its modules exist. That last clause is the awkward one, because the web API is a single django-ninja instance living in core, and academic has to hang its routes on it. If core can’t import academic, who wires them up?
The module wires itself. On startup, academic’s AppConfig.ready() imports the shared api object and mounts its own routers onto it. The dependency points one way: academic reaches into core’s public seam, never the other way around. Django’s app-ready hook runs at django.setup(), so the routes are present for both live requests and the OpenAPI schema export, without a single line in core mentioning academic.
The guard is an import-linter contract that forbids academic from importing core’s private plumbing or any sibling module:
[[tool.importlinter.contracts]]
name = "academic imports only core's public seam"
type = "forbidden"
source_modules = ["turmarium.academic"]
forbidden_modules = [
"turmarium.core.auth", "turmarium.core.jwt", "turmarium.core.middleware",
"turmarium.core.tenancy", "turmarium.core.managers", "turmarium.core.registry",
"turmarium.gamification", "turmarium.ai", "turmarium.exams",
]
It passed while academic was an empty package. It broke the first time academic imported anything real.
Using a door versus picking a lock
The break is a detail of how import-linter reads a forbidden contract. By default it follows the whole import graph, not just the imports written in your files. Academic imports core.api, which is allowed: that’s the seam. But core.api imports core.auth to build the JWT authenticator. So the graph has a path from academic to auth (academic to core.api to core.auth), and the contract counts that transitive path as academic reaching into auth. Same story for academic to core.models to core.managers. Both are the module using the public seam exactly as designed, reported as violations.
The one-word fix is allow_indirect_imports = true, which tells that one contract to check only direct imports. That matches what I actually mean by the rule. Academic must not name core’s internals in its own code. What the public seam imports underneath is the seam’s business, not the module’s.
Flipping a flag to turn a red check green is the exact move that quietly guts a control, so I didn’t take it on faith. I proved the contract still bites. I added a direct from turmarium.core import tenancy to an academic file, ran the linter, and watched the contract break. Then I deleted the probe and watched it go green. Direct reach into the plumbing is still caught. Only the transitive path through the sanctioned door is allowed. A boundary contract is worth having only if it can tell the difference between using a door and picking a lock, and now I’ve seen mine do both.
The route that shadowed itself
Test-driven development is the principle I build everything on, this project included. Writing the test before the code forces me to name the behavior I want before I have anything to lean on. This time it caught a bug that leaves no mark on the screen. The plan I work from got the genuinely fiddly part right: reordering a syllabus’s units without tripping the unique (syllabus, order) constraint, which fires mid-statement, so a naive one-pass renumber collides on a swap and you have to move the rows out of the way first. The bugs that survived were in the boring parts, and the ugliest of them was red in a test long before it could reach a browser.
The best one was a routing shadow. Reordering units posts to /syllabi/{id}/units/reorder. Editing a unit patches /syllabi/{id}/units/{unit_id}. I declared the reorder route after the {unit_id} one and assumed it was safe, because the handler signature reads unit_id: int and reorder is not an integer. It matched anyway: the path converter django-ninja generates accepts any segment without a slash in it, digits or not. Django then resolves URLs by shape, first match wins, before it ever looks at the HTTP method. So a POST to .../units/reorder bound unit_id = "reorder", found only PATCH and DELETE registered at that pattern, and returned a 405. The fix is to declare the literal /reorder route ahead of the {unit_id} one. A one-line swap, invisible on the page, red the instant a test hit it.
Two smaller ones rode along, both in code that read as correct until it ran: a React test that sliced a header off a table that had none, and a TypeScript constant used only as a type, which tripped the unused-variable lint.
What I left half-built on purpose
One flow works only inside a single session, and I shipped it that way with my eyes open. When you create a discipline’s syllabus in the browser, the screen keeps its id in local state and everything works: add units, reorder them, edit. But the discipline record the API returns doesn’t carry the syllabus id, and there’s no “get the syllabus for this discipline” endpoint yet. Navigate away and come back, and the detail screen sees no syllabus, offers to create one, and the create hits the one-syllabus-per-discipline rule with a 409.
The data is safe on the server. It’s just unreachable from the UI across sessions. No leak, no corruption, and the user sees it the moment it happens, so it’s a fast-follow, not a blocker: add the syllabus id to the discipline response (or a lookup endpoint), regenerate the client, read it on load. A separate review of the isolation on the four new tables came back clean, with one note for the same list: the patch endpoints copy request fields onto the row by name, which is safe today because the update schemas expose only harmless fields, but would let a carelessly widened schema reassign a row’s owner.
Two more items went on that list. routers/disciplines.py sits at 72% coverage, and the holes are specific: get_discipline, the success path of patch_discipline, and delete_discipline have no direct tests at all. They’re typed and published in the OpenAPI schema, and nothing in the browser calls them yet, which is exactly how they slipped past a test list I wrote myself. The suite is green and 72% of that file is a guess.
The other is a debt from update 2, where I said the Playwright specs only protect me when I remember to run them. This slice added three more, for disciplines, the syllabus editor, and terms, and I didn’t run any of them for the completion review, because they still want a seeded admin and a reachable API. The syllabus one hedges its own click:
const createSyllabus = page.getByRole("button", { name: "Create syllabus" });
if (await createSyllabus.isVisible()) await createSyllabus.click();
That if is the test declining to know whether the syllabus came back. It’s the revisit bug above, sitting inside the spec written to catch it. Naming these is cheaper than pretending the feature is done.
What I learned
import-linter’s default, follow every transitive path, sounds stricter than checking direct imports, but for a module built to use a public seam it flags the intended usage and buries the question I actually care about: does my own code name the internals directly? Direct-import checking, plus a probe that proves the check still catches a real violation, is the version I trust.
The uncomfortable half of that is what a green check doesn’t cover. The contract can’t see whether disciplines.py has tests behind its 72%, and it can’t see that the syllabus spec skips its own assertion. It proves the walls are where I put them, and it has nothing at all to say about the code living inside them.
Where it landed: 95 backend tests and 62 web tests green, import-linter reporting two contracts kept and zero broken, API coverage at 95% and the browser app at 92% of statements and 81% of branches, all above the 80% floor.
What’s next
The catalog is the vocabulary; next is using it. Offerings (a discipline taught in a term to a class), enrollments, and the domain events the whole modular design exists for: EnrollmentCreated and its siblings, emitted by the core and consumed by a module the core still knows nothing about. That’s the first time the event bus from update 1 carries real traffic, and the first module that reacts to the core instead of only reading it.
A question, then, for anyone who runs an enforced module boundary (import-linter, ArchUnit, eslint boundaries, whatever yours is). Do you check direct imports or the full transitive graph, and once you start adding exceptions for the legitimate crossings, how do you keep that list from quietly becoming the hole in the wall? Mine has exactly one exception now and a probe test standing guard over it. I’d like to know how you keep yours honest.
I'm building this in the open, one update at a time.
Keep reading
- Shouting into an empty room: offerings, enrollments, and the first domain event I shipped with no subscriberUpdate 4 of the Turmarium build log. Turmarium is a multi-tenant B2B school-management SaaS, English-first with pt-BR first-class. Updates 1-3 proved tenant isolation at the database, switched it on for real traffic, and built the academic catalog behind a strict import boundary. Update 4 makes the architecture finally do something: offerings (turmas, a discipline taught in a term to a section) and enrollments, two more tenant-scoped tables with forced RLS, plus the first domain event, EnrollmentCreated, emitted onto the event bus that has sat unused since update 1. The honest twist: the event fires into an empty room. No module subscribes yet (that is a later epic); only a test listens. Which is lucky, because writing this update is how I discovered the emitter does not do what I designed it to do: it fires inside the still-open request transaction, not after commit, and the in-process test that covers it cannot see the difference. PROTECT keeps a class you actually ran from being deleted by accident, limit_choices_to turns out to guard the form and not the API, and update 3's half-built syllabus revisit gets paid off first. 124 backend tests, 80 web tests, import-linter at two contracts kept and zero broken, coverage near 97% on the API and above the gate on the browser app.July 17, 2026
- Green tests, red browser: turning on tenant isolation for real trafficUpdate 2 of the Turmarium build log. Turmarium is a multi-tenant B2B school-management SaaS, English-first with pt-BR first-class. Update 1 proved Postgres row-level security at the database but left the request-path middleware a no-op, so it shipped zero tenant-data endpoints on purpose, behind a written gate. Update 2 closes that gate: the RLS session variable now gets set inside JWT auth, the first tenant-data API (memberships and users) ships behind role guards, and a React + Vite + Tailwind front end drives login, forced password change, and org switching through a client generated off the OpenAPI schema. Hardening the policy turned up a Postgres cast that crashes during planning no matter what guards surround it. Then driving the finished app in a browser turned up two more bugs the green unit suite never saw: switch-org failing closed under its own RLS, and a CORS wall the front end hit on its first request. 66 backend tests, 97.58% coverage, and the isolation gate from update 1 finally closed.July 8, 2026
- Starting Turmarium: proving one school can't read another's dataUpdate 1 of a new build log. Turmarium is a multi-tenant B2B school-management SaaS, built English-first with pt-BR first-class, where the academic core is a stable spine and every future capability plugs in as a module through contracts and domain events, never by importing the core. This first slice is the foundation: a Django 6 + Ninja modular monolith, JWT auth with org/role claims, and tenant isolation proven at the database with Postgres row-level security. 19 tests, coverage 82%, and zero tenant-data endpoints yet, on purpose.July 6, 2026
Get the next update by email
Build-in-public updates and new posts, delivered as a digest. Double opt-in · no spam · unsubscribe anytime · handled by Buttondown.