@jpgilldev / steid

steid/plans/architecture.md
5.1 KBRaw
1# Architecture
2
3## Layers
4
5DDD + clean architecture. Dependencies point inward. The domain knows nothing about
6HTTP, SQL, git, or Topcoat.
7
8```
9web / ssh (interface) → application (use cases) → domain (entities, ports)
10
11 infrastructure (sqlite, git binary, argon2)
12 implements the ports the domain/application declare
13```
14
15| Layer | Holds | Must not import |
16|---|---|---|
17| `domain` | entities, value objects, typed IDs, repository traits, `DomainError` | anything infrastructural |
18| `application` | use cases, ports (`GitStorage`, `GitProtocolServer`, `PasswordHasher`), `AppConfig` | concrete adapters |
19| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, sessions, web handlers | — |
20
21**Every operation takes an `Actor`.** Authorization is checked in the use case, before
22any side effect. Not in the handler, not in the adapter. The previous attempt got this
23right and it paid off — `serve_push` could enforce `Role::Owner` in one place
24regardless of transport.
25
26## Conventions
27
28These come from two prior attempts. They earned their place.
29
30### Typed IDs — never raw strings for entity references
31
32```rust
33// CORRECT — the compiler catches a swapped argument
34fn get_member(&self, org_id: &OrgId, user_id: &UserId) -> ...
35
36// WRONG — silently compiles, fails at runtime
37fn get_member(&self, org_id: &str, user_id: &str) -> ...
38```
39
40`UserId`, `OrgId`, `MembershipId`, `RepoId`, `InviteCodeId`, `SshKeyId`.
41
42### `new()` validates, `from_trusted()` doesn't
43
44`new()` is for user input and returns `Result`. `from_trusted()` is for rows loaded
45out of the database, which were validated on the way in. Infrastructure repos should
46always use `from_trusted()` — re-validating DB rows means a validation-rule change
47turns old rows unreadable.
48
49### `from_str` returns `Result`, never `Option`
50
51A silently-defaulted enum parse is a bug that surfaces days later as wrong
52permissions. Applies to `Role`, `Visibility`, `RegistrationPolicy`.
53
54### Every repository port gets two implementations
55
56An in-memory one and a SQLite one. The in-memory one is what makes use cases testable
57without a database — attempt #2 reached 60 tests this way and they ran fast enough to
58stay in the loop.
59
60### Ports live where they're consumed
61
62Repository traits in `domain/repository/`. Service ports the application needs
63(`GitStorage`, `GitProtocolServer`, `PasswordHasher`) in `application/port.rs`. Use
64cases take `&impl Port`, not a boxed trait object.
65
66Git is deliberately several narrow ports rather than one service, and how the binary is
67actually invoked lives in one place in `infrastructure/git.rs` —
68[0006](decisions/0006-git-binary-behind-narrow-ports.md).
69
70## DB-plus-filesystem writes
71
72Creating a repo writes to two places that can't share a transaction: the
73`repositories` row and the bare repo on disk. Neither prior attempt fully solved this.
74
75Attempt #1 used a **compensating transaction** — create on disk first, and if the DB
76insert fails, delete the directory:
77
78```rust
79let repo = self.repository.create(&id).await?;
80if let Err(e) = self.repo_record_repo.create(&record).await {
81 let _ = self.repository.delete(&id).await; // compensate
82 return Err(e.into());
83}
84```
85
86That leaks an orphaned directory if the process dies between the two calls. Good
87enough to ship; write down that it's a known hole rather than rediscovering it. The
88durable fix is a reconciliation sweep on boot, or marking rows pending and committing
89after the filesystem write lands.
90
91## Data access
92
93Topcoat's grain is async components that query the database directly and check
94permissions inline, with `#[memoize]` deduplicating calls per request. State comes from
95`app_context::<T>(cx)`, and the framework's own guidance is to "prefer composable
96`cx: &Cx` functions over middleware/extractors for auth and request-scoped data."
97
98That reads as a view layer reaching for the data layer, which the rules above forbid.
99It was a genuinely hard question while SSH was the git transport, because an SSH
100channel handler has no `Cx` — so authorization expressed in a component was invisible
101to `serve_push`, and the two had to share something.
102
103[Decision 0001](decisions/0001-git-over-http-not-ssh.md) removed that transport. Every
104caller now has a `Cx`, so nothing *forces* a transport-neutral use case layer. The
105position is therefore a choice, taken deliberately:
106
107**Writes and authorization go through use cases. Reads may go direct.**
108
109- Any mutation, and any decision about what an actor is allowed to do, lives in an
110 application use case that takes an `Actor` and the ports it needs. One place, one
111 answer, reachable from a page, an `/api` route, or a future SSH adapter alike.
112- Straightforward reads for display may query through the pool in a component, which
113 is where memoization and per-component fetching earn their keep.
114
115The tell for when a read has outgrown that: if it starts deciding whether the viewer is
116allowed to see something, it isn't a read any more — move it.
117
118Revisit if `/api` and the pages start duplicating query logic. That's the signal the
119line is in the wrong place.
120
121## Safety
122
123Safe Rust only. No `unsafe`.