**Version:** `twenty-partners@1.3.2` (patch — internal refactor, no
visible behavior change)
## What & why
Reorganizes the `twenty-partners` SDK app from a flat, type-first layout
(`src/{objects,fields,views,logic-functions,front-components,…}`) into a
**vertical-slice** layout under `src/modules/<domain>/<feature>/`. Files
that
change together now live together; each SDK entrypoint is a thin
discoverable
shim over a service + graphql-ops + mapper/connector layer.
This is a pure structural refactor — **no object, field, view, enum,
logic
function, trigger, role, or application variable changed.**
## Final layout
```
src/modules/
shared/ http · services · graphql · utils · front-components · navigation-menu-items (cross-domain nav folders)
opportunity/ fields·view-fields·views·navigation-menu-items·page-layouts·constants + intake/ + matching/
partner/ objects·fields·constants·utils + directory/ · self-service/ · marketplace/ · application-intake/ (Discord connector/)
application/ objects·fields·views·navigation-menu-items·page-layouts + services · graphql
```
Every `defineLogicFunction` entrypoint is now a thin
`*.logic-function.ts`
(all < 40 lines) at its domain/feature root, delegating to a
`*.service.ts`;
graphql operations live in `graphql/{queries,mutations}/`, pure
transforms in
`mappers/`, outbound APIs (the Discord webhook) in `connector/`, pure
helpers
in `utils/`.
## Safety — the load-bearing invariant
The server diffs app primitives by `universalIdentifier`, so a
changed/dropped
UUID would drop-and-recreate the object on prod (data loss). This branch
holds
that line:
- **887 `universalIdentifier`s byte-identical** to the branch base
(every
relocation is a `git mv`; every extracted entrypoint keeps its original
UUID/name/trigger verbatim). Re-verified byte-identical across the
rebase.
- `yarn twenty dev --once` against a live workspace = **"No changes.
Twenty
metadata matches your manifest."**, confirmed idempotent on a second run
—
the whole refactor is a metadata no-op (zero create/delete/identity
change).
- Every extracted graphql op was verified **byte-identical** to its
original
(args, `first:` caps, pagination, selection sets), and the
partner-application
Discord embed's deliberate PII omission (no email / hourly rate) is
preserved.
## Rebased onto latest `main`
This branch is rebased onto `main` (`d20e5378fd`) and now carries main's
`twenty-sdk` / `twenty-client-sdk` **2.23.0-alpha.2** bump.
Note for reviewers: main had independently bumped this package to
`1.3.1`, so
the original `1.3.0 → 1.3.1` commit here was redundant and git dropped
it during
the rebase (`patch contents already upstream`) — with **no textual
conflict**,
since both sides wrote the same version string. The bump is therefore
now
**`1.3.2`**. The rebase touched only `package.json` and `yarn.lock`;
**every line
of refactored source is byte-identical** to the pre-rebase tree.
## Verification
All run on the rebased tree, against SDK `2.23.0-alpha.2` and a live
Twenty
server `v2.23.2`:
| Check | Result |
|---|---|
| `universalIdentifier` set | 887, byte-identical |
| `yarn twenty dev --once` | "No changes" (idempotent on re-run) |
| Typecheck | pass |
| `yarn lint` | 0 warnings, 0 errors (287 files) |
| `yarn test:unit` | 158/158 (23 files) |
| `yarn test:integration` | 45/45 (13 files) |
## Also in this PR
- **Architecture convention doc** — `AGENTS.md` (+ a one-line
`CLAUDE.md` pointer)
at the package root documents the vertical-slice conventions this
refactor
establishes: the layout, the dependency rule (`logic-function → service
→
graphql/connector`), file naming, connector = outbound-only (inbound
webhooks
are logic-functions), and the UUID invariant. It ships here so the doc
and the
structure it describes land together.
- **`modules/shared/`** dedup: the secret-guarded intake envelope, the
find-or-create-company/person helpers + their graphql ops, `collectAll`
pagination, `http-url`/`strip-markdown`/`is-non-empty-string` utils.
- **Vitest configs collapsed** into one `vitest.config.ts` with `unit` +
`integration` projects (`yarn test:unit` / `yarn test:integration`).
- Cross-domain nav folders (`pipeline-folder`,
`partner-workspace-folder`)
hoisted to `modules/shared/navigation-menu-items/`.
## Deferred (non-blocking, tracked follow-ups)
- Add direct unit tests for the shared `collectAll` / `isNonEmptyString`
utils
(currently covered indirectly).
- Move `submit-client-brief`'s zod schema out of its mapper file into
its own
schema file (mirroring the partner side).
- Route `stamp-partner-user-on-child` through the shared self-service
mutation ops.
- `find-partner-by-member.ts` is duplicated identically in the
`application` and
`self-service` domains; a candidate to hoist into `modules/shared/`.
6.2 KiB
Architecture — twenty-partners
Vertical-slice modules, one folder per domain. Adapted from twentyhq/twenty-eng
(the largest Twenty SDK app), which migrated flat → modular as it grew. Repo-wide
conventions (kebab-case files, named exports, no any, types over interface,
short // comments) live in the root CLAUDE.md and still apply — this file only
adds the partners-specific structure.
Status: migration complete — every domain lives under
src/modules/. The only top-levelsrc/entries left areconstants/universal-identifiers.ts,scripts/,roles/,skills/,workflows/,__tests__/, and the two root config files. Add new code to a domain module, never a flat top-levelsrc/<primitive>/folder.
Layout
src/
application-config.ts # app + application variables — unchanged
default-role.ts # unchanged
constants/universal-identifiers.ts # ⚠️ MUST NOT MOVE (see Invariants)
scripts/ # ⚠️ MUST NOT MOVE — seed/purge/rls/slugify
roles/ # app-wide RLS — stays at root (spans every object)
skills/ # bundled Claude skills — unchanged
workflows/ # bundled workflows — unchanged
__tests__/helpers/ # shared test setup (client, fixtures, cleanup)
modules/
shared/ # cross-cutting: secret-guard (http/), find-or-create, paginate,
# utils, front-components, cross-domain nav folders
<domain>/ # partner · application · opportunity · … (large domains add a <feature>/ tier)
<name>.logic-function.ts # thin SDK entrypoints — sit at the domain/feature root, NOT in a subfolder
objects/ fields/ view-fields/ views/ navigation-menu-items/ page-layouts/ # declarations
constants/ # value lists + declaration UUID maps (NOT types)
services/ # business logic (testable)
graphql/{queries,mutations}/ # the only place raw queries/mutations live
connector/ # OUTBOUND third-party API per folder (<name>.connector.ts · config · types)
mappers/ utils/ types/ front-components/
A domain is a self-contained slice: its declarations (objects, fields, views, …)
and its logic (logic-functions → services → graphql/connector) live together.
Adding a feature means adding a folder, not scattering files across ten flat piles.
Feature tier for large domains: when a domain holds several distinct features, insert a
<feature>/ level — modules/<domain>/<feature>/<primitive>/ (the twenty-eng standard,
e.g. modules/code-build/build-task/services/). It keeps graphql/ and services/ from
becoming flat piles. Partner uses partner/{self-service,marketplace,application-intake, directory}/…; opportunity uses opportunity/{intake,matching}/…. A small single-feature
domain (application) may keep <domain>/<primitive>/ directly.
The dependency rule (the one that matters — never import upward)
logic-functions → services → { graphql, connector, mappers } → shared · utils · types
- Entrypoints (
*.logic-function.ts, at the domain/feature root) — SDK entrypoint ONLY. Parse input, call one service, return. No business logic, no raw GraphQL, no external HTTP. >~40 lines means it's doing a service's job — extract it. services/— all business logic. Testable, no SDK/transport coupling. Must not import alogic-function.graphql/— the ONLY place raw queries/mutations live (named typed operations, not inlineclient.query(...)).connector/— the ONLY place outbound third-party APIs are called; one folder per API (<name>.connector.ts/config.ts/types.ts). Inbound webhooks are NOT connectors — a webhook the app receives is an ordinarylogic-functions/entrypoint guarded by the shared secret-guard util. (Today the one real connector is Discord; TFT/client-brief/ partner-application are inbound.)- Never import logic sideways between domains — share via
modules/shared/. (A relation field naturally referencing its target object's ID constant is a schema reference, not a logic dependency — that cross-module import is allowed.)
Naming
<name>.<primitive>.ts — e.g. submit-application.logic-function.ts,
resolve-candidacy.service.ts, partner.object.ts. Files are kebab-case (no PascalCase,
even for React components: profile-picture-upload.tsx).
Tests co-locate with their subject and split by kind: <name>.test.ts = unit (mocked
client, fast, no infra); <name>.integration-test.ts = real workspace (needs a live
server + global setup; excluded by tsconfig). The split is a selectable vitest project
(--project unit), not a file-count mandate — one vitest.config.ts with two projects is
preferred. Don't rename a mocked unit test to .integration-test.ts. Shared test setup
lives in src/__tests__/helpers/.
Invariants (moving code must not break these)
- The set of
universalIdentifierUUIDs must stay byte-identical across a move — the SDK tracks primitives by identifier, not path, so a puregit mvis safe but a changed/dropped UUID re-registers or orphans an object. - Do not move
src/constants/universal-identifiers.ts— the env-toolkit rewrites it by path per bundle. - Do not move
src/scripts/*—package.jsoninvokes them by path. - Keep importers of
src/constants/universal-identifiers.tspointing at the root file — don't relocate its UUID exports into a module. - Never split a relation across domains with a dangling ID import. Paired relation
fields export/import each other's field-ID constant; keep both sides of a relation in one
domain, or hoist the shared ID into
shared/. - One domain per commit when migrating (
git mvonly, imports fixed in the same commit).
Where does new code go?
External API call → connector/. Raw query/mutation → graphql/. Any logic →
services/. New SDK trigger/function → a thin *.logic-function.ts at the domain/feature
root that calls a service. Shared across domains → modules/shared/.