7.7 KiB
Clean Architecture Migration (hono-cf-clean-arch)
Living tracker for migrating server/ to the canonical layout: domain → usecases → adapters → http, with a composition.ts root and dependency-cruiser
enforcement. Strangler-style: every commit is behavior-preserving and leaves
pnpm typecheck + pnpm test green. One PR, many green commits.
Target layout
server/
domain/ pure business rules (no node_modules except shared/)
usecases/ application operations; take `deps` first
ports.ts barrel re-exporting ports/<resource>.ts
ports/ framework-free port interfaces + DTOs (one file per resource)
deps.ts the Deps aggregate
adapters/
repos/ drizzle repositories (the ONLY place schema/drizzle is imported)
gateways/ external services that aren't HTTP-API providers (s3, email, …)
providers/ external HTTP API clients
http/ hono routes (already split per resource); get deps from context
auth.ts better-auth (named drizzle exception)
composition.ts createDeps(platform): the only place adapters are constructed
db/ drizzle schema + client
Conventions (the recipe applied per resource)
- Port:
usecases/ports/<r>.ts— plain DTOs + repo/gateway interface. No drizzle, no zod runtime (type-only shared imports OK). Addexport * from './ports/<r>'tousecases/ports.ts. - Adapter:
adapters/repos/<r>.ts—create<R>Repo(db): <R>Repo. Maps rows → DTOs. - Wire: add to
usecases/deps.ts(theDepsinterface) andcomposition.ts. - Rewire callers:
http/+ middleware →c.get('deps').<r>.<method>(...)- not-yet-migrated
services//auth.ts→create<R>Repo(db).<method>(...)(transitional; removed when that service itself migrates)
- Delete the old
services/<r>.ts; move its co-located data tests next to the repo. pnpm typecheck && pnpm lint && pnpm testgreen; commit.
Imports: relative within server/ (matches existing code); @shared/* for shared.
Progress
Done
-
Step 1
routes/ → http/rename -
Backbone
composition.ts+usecases/deps.ts+usecases/ports/+ deps middleware -
activity→adapters/repos/activity.ts(ActivityRepo) -
storage→adapters/repos/storage.ts(StorageRepo) -
profile→ ProfileRepo;buildBreadcrumb→domain/breadcrumb.ts -
announcement→ AnnouncementRepo -
notification→ NotificationRepo -
test infra:
createApp(platform, auth, deps?)+createTestAppreturnsdeps, so tests fake a port by spying ontestApp.deps.<x>(replaces cross-boundary module spies, e.g. events SSE unread-count failure) -
org→ OrgRepo (authz queries) ·invite→ InviteRepo -
background-jobs→ BackgroundJobRepo (+ BackgroundJobError to ports) -
effective-quota→ QuotaRepo (currentTrafficPeriod→ domain/quota); unblocks team / storage-usage / matter / cloud-traffic-metering. 14 callers + entry-node + workers/scheduled rewired. -
team→ TeamRepo (uses QuotaRepo internally) ·team-invite→ TeamInviteRepo -
site-invitations→ SiteInvitationRepo ·cf-custom-hostnames→ CfHostnamesProvider ·changelog→ ChangelogProvider ·instance/license-binding → InstanceRepo/LicenseBindingRepo ·s3→ S3Gateway (shim) ·system-options→ SystemOptionsRepo ·image-hosting-config→ ImageHostingConfigRepo -
user+org-entitlements-> UserAdminRepo (combined; resolves the user/org-entitlements cycle) -
storage-usage→ StorageUsageRepo + reserve/withReservation usecase (the quota-reservation crown foundation)
Parallel migration waves (file-disjoint components migrated concurrently by subagents; barrels wired by the orchestrator)
All waves done. Each wave: agents migrated disjoint components (new ports/adapters/usecases/domain,
rewired callers to c.get('deps')), the orchestrator wired the 3 barrels and ran the gates.
- Wave 1 — instance-telemetry, image-upload, archive-jobs, zip-compress/extract, object-upload-sessions, purge
- Wave 2 — auth-account (signup-mode-guard, team-count-guard, captcha, email, share-notification), webdav-middleware (download-tokens, api-keys, webdav-state/path/xml), cloud (licensing-cloud, cloud-store, cloud-traffic-metering→cloud-traffic-report, remote-download-usage, licensing-refresh-runner), branding, image-hosting
- Wave 3 — share + save-to-drive, archive-processing (+archive-target-folder), trash-retention
- Wave 4 — the matter keystone (MatterRepo + matter usecase + matter-name-conflict→domain) + site-public-origin
- Wave 5 — downloads (DownloaderRepo + DownloadTaskRepo + state-machine usecase); then the s3 shim deleted
Status: COMPLETE — architecture fully locked
server/services/ is empty and removed. Every drizzle access lives in adapters/repos/; every route
gets its dependencies from c.get('deps'). The ratchet is empty and removed — including the final two
files: http/webdav.ts (listDescendants/proppatch/PUT-overwrite/Basic-Auth → MatterRepo.{listActiveDescendants,
trashByIds,restoreActiveByIds,touch,applyUpload} + UserAdminRepo.{isBanned,matchesActiveUsername}) and
middleware/auth.ts (disabled-user check → UserAdminRepo.isBanned). Gates green: typecheck,
lint:arch (267 modules, no-circular + drizzle-only-in-repos fully enforced, zero exemptions),
lint:spec, lint, test (3810), test:cf (57).
Enforcement — DONE, fully locked
.dependency-cruiser.cjs+lint:archin CI. All clean-arch rules active with no migration allowlist.platform/(Database driver type),server/test, andauth.ts(better-auth owns its tables) are the only permanent named exceptions todrizzle-only-in-repos.
Product specs (BDD-lite) — DONE
spec/Gherkin.feature(one per capability) +spec/README.md;[spec: <id>]breadcrumbs on home tests;pnpm lint:spec(CI) enforces traceability both ways.- 418 scenarios across 30 capabilities: storages, announcements, notifications, invite-codes, site-invitations, quotas, profile, licensing, users, audit, teams, avatar, background-jobs, events, health, branding, email-config, auth-providers, system, image-hosting, webdav, quota-store, redirect, download-tasks, shares, objects, image-hosting-config, licensing-admin, teams-admin, auth-username.
Structure cleanup — DONE
- Dissolved the unclassified
server/licensing/feature dir into the layers (it had escapeddomain-stays-pure/usecases-no-infrastructure):public-keys→domain/license-keys;verify+cloud-event-token→usecases/license-certificate;entitlement/instance-info/refresh→ deps-first usecases (license-entitlement/instance-info/license-refresh, using existingdeps.{licenseBinding,instance,licensingCloud}— no barrel changes). 11 consumers rewired todeps. Remaining non-layer dirs are intentional:platform/+test/+auth.ts(named exceptions),lib/(framework-free utils),middleware/(Hono delivery convention).
Post-review follow-ups — DONE
- Deduped the transitional matter-row DTOs:
ShareMatterRow/WebDavMatterRownow reference the canonicalMatterport DTO (removed the hand-copied duplicates + their schema-drift risk). - Hoisted shared stateless instances in
composition.ts(singles3/storages/systemOptions). - Removed the 21 dead
const db = c.get('platform').dblocals — biome is now warning-free. MatterRepo.listActiveDescendantsuses SUBSTR exact-prefix (not LIKE) — correctness for names with_/%.