Plan 60 Backend Architecture - SekiroKenjii/Quizzivy GitHub Wiki
Decided 2026-09-06 after the owner's IT review of the Go source. Supersedes the "one package per feature" layout AGENTS.md described until then.
-
One process, one database, eleven bounded contexts under
server/internal/modules/: identity, classes, questions, media, tests, assignments, attempts, dashboard, imports, availability, access. Each is one aggregate (dashboard is read models only; availability reads the maintenance windows the operator writes; access reads roles and grants and resolves who a request acts as, with no transport of its own until R5's Roles page) and exposes its operations through its ownhttp/package, which core embeds into the generated strict server. -
Four layers per module โ
domain/,application/,repositories/,http/โ with the dependency direction pinned bycore/tests/architecture_test.go.platform/holds technical adapters and knows no module;shared/is the kernel and depends on nothing above it;core/is the composition root and the only place that knows everything. -
Managers, not services, in the domain. A domain rule that belongs to no single entity is a
*Manager(GradingManager, DealManager, TimelineManager, InterventionManager, PublishManager, ScheduleManager, JoinCodeManager, PasswordManager, QuestionManager, AssetManager), reached as a package value. The application layer speaks in commands and queries. -
CQRS in the application layer (second review, 2026-09-06). Every module's
application/isApplication{Commands, Queries}built byNew:command/andquery/hold one struct and one handler per use case (command.Create/command.CreateHandler.Handle(ctx, cmd)), typed by the shared contracts inshared/cqrs(CommandHandler[C, R],QueryHandler[Q, R],HandlerFuncfor stubs,Nothingfor commands that only succeed or fail,Errfor callers that keep only the error).ports/is what the module needs from outside,model/the results other modules read,internal/support/the ports, clock and helpers the handlers share. A command that acts for a signed-in user carriesshared/actor.Actor. A query never writes: the attempts monitor screen runs theExpireDuecommand and then theMonitorquery. -
Ports at the application boundary. identity declares
GoogleProvider; media declaresAudioProbeandObjectStore; questions declaresMediaKinds; classes and identity read student figures throughshared/stats.Source, which attempts implements, over only the assignments the reader reaches; tests' repository takesQuestionLocksandMediaLocksfor the row locks another module holds inside its transaction, and for the ownership and readability checks that run under those locks (NotOwnedBy,RequireReadable). A port one operation fills is typed as the other module's handler (identity'sSelfEnrolleris the classesEnrolNewMembercommand; attempts'Studentsis the identityStudentAccountquery); the restcore/adaptersadapts. -
Reach rules in the kernel.
shared/visibilityholds the rules every teaching read shares, each defined once as SQL:-
TaughtClassIDs: the classes a teacher teaches; -
TaughtMemberIDs: the accounts in those classes; -
AuthoredAssignmentIDs: the assignments a teacher created; -
StudentIDs: the students a teacher reaches; -
AssignmentIDs: the assignments a teacher reaches; -
Papers: which attempts a list of papers shows; -
PaperReaders:Papersread the other way, the teachers who reach one paper (the assignment's creator and the teachers of a target class the student belongs to), which notifications use to tell exactly them; a test holds it equal toPapersover a world with every route to a student; -
Roster: the accounts an assignment is addressed to.
identity, classes, assignments, attempts and dashboard splice them into their own statements, and callers lift them under
scope.all. Likeshared/audit, it is SQL in the kernel, because a port cannot splice a predicate into another module's list or aggregate. -
-
Telling after commit. A module that causes a notification declares a
Notifierport typed as the notificationsNotifyhandler (attempts, assignments and classes each do; wiring hands themnotificationsApp.Commands.Notify), and tells throughshared/cqrs.Announceonce its own transaction has committed.Announceruns abuildfunction that reads what the notice says and returns the commands, then hands each to the handler under a ten-second budget and a context that keeps the request's values and not its end, so a client that went away after the commit still gets its teacher told. A failure of either is logged once with how many commands went undelivered and is never returned; a nil handler reads nothing. A repository that cannot know a notice's audience returnsdomain.Milestones(attempts: the paper was handed in, flagged by the policy, or graded) and the command asksBriefingfor the rest in a read of its own, so nothing about a notification runs inside the write. attempts'Announcerand assignments'TellMovedandTellGrantedbuild the commands; the dedupe key formats arenotifications/domain/dedupe.go's, in one place. -
The answered rule in the kernel.
shared/answered.SaysSomethingis the one SQL definition of a saved answer that says something. assignments (the student's card) and attempts (the teacher's monitor) splice it, so the two counts cannot drift. -
The status rule in the kernel.
shared/schedule.DerivedStatusis the one SQL definition of an assignment's status (draft, scheduled, open or closed) from its window, over a row ofapp.assignmentsaliaseda. assignments (its list, facets and reopen) and tests (the per-test counts onTest.assignments) splice it, so the two cannot drift, and a change to the window rule is made there once. The same package holds the close rule:CloseOfis the SQL for the moment an assignment stops taking attempts (least(closed_at, closes_at), or the later of that and a student's override), andCloseandWindow.WithOverrideare its Go twin. attempts uses them to hold a result back until the close and to decide whether the class average may show (T-R4.11). The same package reads a student's override:OverrideJoinis theLEFT JOINon one student andOverrideColumnsscans it, so the student's card and intro (assignments) and a start, a resume and a result (attempts) all take one student's window, time limit and attempts from one rule (T-R4.12). The override feeds the reader's own close and never the assignment's, which is what the class average waits for. The same package moves the deadlines of attempts in progress (T-R4.12b):LockWindowstakes the maintenance-windows lock shared, andRecomputeDeadlineslengthens each live attempt's deadline to the window's, only ever later and audited, in the transaction that changed the window. assignments callsLockWindowsbefore it locks the assignment row in an extension, an update, a reopening, an override write and an override delete, andRecomputeDeadlinesafter the write in all but the last; attempts' start takes the same lock and then the assignment rowFOR SHARE, so the lock order is the same everywhere: windows, assignment, attempts. The kernel owns the statement because assignments writes the window, the rows it moves are attempts', and the two modules do not import each other. -
Access in the kernel.
shared/accessis the authorization model every layer may use, and it imports only the standard library: the catalogue asKeys, with the pseudo-keysself,workspace.teacherandworkspace.admin; a role's grants as aSet; the any-ofRequirementan operation declares inx-permission;Workspaces; thePrincipala request acts as and theScopeit reaches (Allonly forscope.all;Scope.Ownclears it, and the teacher workspace's six content lists pass that, T-R4.54);IsStudentLike, which isapp.student_like_rolesin Go; andCanActOn, the subset rule. The access module fills it:core/adapters.Principalsputs itsResolvePrincipalbehindhttpx.RequirePermissionand the docs gate, and identity'sPrincipalsport is the access application (Resolve,Forget).shared/actor.Actorcarries theScope, and every scoped repository filters by it (plan 70 ยง4.2). -
One database context, one repository base.
platform/db.Contextwraps a pool or a transaction (Exec,Query,QueryRow,InTx); every module Postgres type embedsdb.Repositoryand uses the genericQueryOne,QueryMany,Count,Exists,IsUniqueViolation,EscapeLike. Optional values go throughshared/opt, field errors throughshared/validation. -
A table that leaves the product goes through
platform/tabular(T-R4.13):EscapeCellprefixes an apostrophe to a cell a spreadsheet would run as a formula, and theCSVwriter passes every cell through it after a byte-order mark, CRLF-ended. A transport that exports builds its file with it and nothing else; R5 adds XLSX to the package. A time in such a file is written in the caller's own zone, which attempts reads through itsports.Zones, the way dashboard does, wired from identity'sEffectiveZoneincore/wiring. -
core is five packages.
core/wiringbuilds each module (one file per module, in dependency order) and returns theAssembly;core/routerfronts the transports with the generated strict server, the middleware order,/livez,/healthz,/docsand the rate limits;core/adapterstranslates platform errors into domain errors and one module's handlers into another's port;core/jobsruns background commands;core/maintenanceholds the operator's commands thatcmd/maintenanceruns outside the API (retention, anonymisation, maintenance windows and, from R2,rekey-join-codes).platform/httpserverowns the listener and its shutdown. -
Tests in three tiers (unit, integration, e2e โ see server/README.md), every test file in a
<layer>/tests/directory as an external package, none reaching a private identifier. -
Self-served API reference:
/docsis our page loading Scalar's pinned, SRI-checked bundle against/docs/openapi.json, served by the API from the contract it was generated from, behind an admin docs session (spec ยง5.5). -
Comments state contracts, code states the rest: no comment inside a function body, no doc comment on an unexported identifier, one paragraph on an exported one, and a package comment naming the context's model.
| before | after |
|---|---|
internal/api (one Server, every handler) |
each module's http/; core/router/server.go embeds them |
internal/auth, internal/students
|
modules/identity |
internal/join, internal/classes
|
modules/classes (join code as a value object with a manager) |
internal/tests, internal/tests/publish
|
modules/tests (publishing is one repository transaction handed the domain's validation) |
internal/attempts, grading, review, integrity
|
modules/attempts |
internal/{db,storage,config,httpx,ratelimit}, auth/google, media/probe
|
platform/* |
internal/{paging,audit} |
shared/*, plus shared/stats
|
application.Service methods (second review) |
application/command/*.go, application/query/*.go, application/{ports,model}, application/internal/support
|
module-local DB/Postgres/querier types |
platform/db.Context + db.Repository, embedded |
core/{modules,adapters,composite,router,server,jobs}.go |
core/wiring, core/adapters, core/router, core/jobs, platform/httpserver
|
The reviewers named the costs: with SQL, rules and transport in one package per feature, every change touched every concern, and nothing said which module owned which table. The layout above makes ownership a directory, the rules a named type, the boundaries a test, and the tiers of confidence a build tag โ which is what a maintainer inherits when the current team is gone.
Generated from docs/plan/60-backend-architecture.md. Edit that file in the repository; changes made on this page are overwritten.