ADR: Extract libs/shared/target-app from libs/shared/common
Extract libs/shared/target-app from libs/shared/common
Section titled “Extract libs/shared/target-app from libs/shared/common”Context
Section titled “Context”This is a pure-mechanical Nx refactor that pays down debt explicitly tracked
in the parent arc. ADR §15 of _workflow/adrs/x-target-app-header-client-surface.md
(“Critic-pass reconciliation, iteration 2”) closes with:
Trade-off:
shared/commonnow imports two domain tables (activities,spheres), which is a mild violation of the “no domain coupling” intent forshared/common. Tolerable for now; cleaner solution is to extract a dedicatedlibs/shared/target-app/library that depends only ondata-access-dband is consumed by bothfeatures/activitiesandfeatures/wallet. Tracked as a debt follow-up ticket.
This ticket is that follow-up. The spec’s “Why move now” framing restates
it succinctly: libs/shared/common/ is meant to host domain-agnostic
primitives (filters, decorators, pipes, validators, DTO helpers). Today
it reaches into two domain tables (activities, spheres) via
apply-target-app-filter.ts — a small but load-bearing leak. Any future
consumer of shared/common (mobile-shared infra, admin packs, etc.)
inherits that coupling for free. The fix is one cheap Nx library extraction.
The original Phase C placement was forced by an Nx cycle: putting the
helper inside features/activities (where the table imports live) would
close an activities ↔ wallet cycle because features/activities already
imports features/wallet at five call sites (activities.module.ts:17,
activities-client.module.ts:3, bookings.service.ts:18,
bookings-client.service.ts:28, activities-scheduler.service.ts:7).
Landing in shared/common was the pragmatic exit. A dedicated
libs/shared/target-app/ library that depends only on
@tktspace/shared/data-access-db and is consumed by both
features/activities and features/wallet is the correct shape: it
sits below both feature libs in the Nx DAG, owns the target-app concern
end-to-end (resolver + filter, co-located), and frees shared/common
to return to its domain-agnostic charter.
Scope is intentionally minimal: surfaces affected = [], contracts
untouched, no runtime behaviour change, only one backend repo. The
sync:contracts:check gate (AC-10) provides the byte-level proof that
the OpenAPI surface is unchanged.
Decisions
Section titled “Decisions”D1. Library naming and path-alias convention
Section titled “D1. Library naming and path-alias convention”The new library is named target-app (Nx project name; per project.json:name)
and exposed via the path alias @tktspace/shared/target-app. This matches
the existing pattern verified at tsconfig.base.json:18-24:
"@tktspace/shared/data-access-db": ["libs/shared/data-access-db/src/index.ts"],"@tktspace/shared/common": ["libs/shared/common/src/index.ts"],"@tktspace/shared/storage": ["libs/shared/storage/src/index.ts"],"@tktspace/shared/messaging": ["libs/shared/messaging/src/index.ts"],"@tktspace/shared/queues": ["libs/shared/queues/src/index.ts"],The new entry appended in alphabetical-ish order (the existing list is already loose on ordering):
"@tktspace/shared/target-app": ["libs/shared/target-app/src/index.ts"]Template-library choice — clarification on the spec wording. The spec
says “Mirrors the structure of libs/shared/data-access-db/”. That library
exists but is the older shape — its project.json uses
"sourceRoot": "shared/data-access-db/src" and
"outputPath": "dist/shared/data-access-db" (no libs/ prefix). Newer
shared libraries (storage, messaging, queues) all use the modern
shape with libs/ prefix on every path:
| Lib | sourceRoot | outputPath | main / tsConfig prefix |
|---|---|---|---|
data-access-db | shared/data-access-db/src | dist/shared/data-access-db | shared/… (no libs/) |
storage | libs/shared/storage/src | dist/libs/shared/storage | libs/shared/… |
messaging | libs/shared/messaging/src | dist/libs/shared/messaging | libs/shared/… |
queues | libs/shared/queues/src | dist/libs/shared/queues | libs/shared/… |
Decision: use the modern libs/shared/storage/ shape as the template.
The spec’s “data-access-db” reference is a structural shorthand (it lists
the same 9 files); the actual project.json paths should follow the
newer convention since we’re creating a new library. This is consistent
with three of the four reference siblings.
Concrete project.json for the new library:
{ "name": "target-app", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/shared/target-app/src", "projectType": "library", "tags": [], "targets": { "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], "options": { "outputPath": "dist/libs/shared/target-app", "main": "libs/shared/target-app/src/index.ts", "tsConfig": "libs/shared/target-app/tsconfig.lib.json", "assets": ["libs/shared/target-app/*.md"] } } }}Per OQ-3 resolution: "tags": [] (no scope:shared / type:util tags —
the codebase has not adopted Nx tags yet; rollout deferred to a
cross-cutting pass).
D2. File-move method (git mv, byte-identical contents)
Section titled “D2. File-move method (git mv, byte-identical contents)”All four files (apply-target-app-filter.ts, apply-target-app-filter.spec.ts,
resolve-target-app.ts, resolve-target-app.spec.ts) move via git mv,
NOT copy-and-delete. This preserves the rename detection in git log --follow
and git blame, which is non-negotiable because the parent arc’s Phase C
authorship and the critic-pass diff history must survive the cleanup.
File contents stay byte-identical. Verified by reading both source files:
apply-target-app-filter.tsalready importsimport { activities, spheres } from '@tktspace/shared/data-access-db';— already a path-aliased import to the only allowed dependency. No edit.resolve-target-app.tsalready importsimport { SphereTargetAppEnum } from '@tktspace/shared/data-access-db';— same. No edit.apply-target-app-filter.spec.tsimports./apply-target-app-filter(relative sibling). After move, still a relative sibling in the new location. No edit.resolve-target-app.spec.tssame —./resolve-target-apprelative sibling, no edit needed.
AC-2 and AC-3 enforce this byte-identity in the MR diff.
D3. Barrel cleanup pattern (delete helpers/ subtree)
Section titled “D3. Barrel cleanup pattern (delete helpers/ subtree)”libs/shared/common/src/helpers/ currently contains exactly five files
(verified by ls):
apply-target-app-filter.spec.tsapply-target-app-filter.tsindex.tsresolve-target-app.spec.tsresolve-target-app.tsThe four target-app files leave for the new library. The barrel
index.ts (which has exactly two lines today —
export * from './resolve-target-app' and
export * from './apply-target-app-filter') becomes empty. Per OQ-2
resolution: delete the barrel and the directory entirely, then drop
the export * from './helpers'; line from libs/shared/common/src/index.ts
(currently line 9 of 11).
Post-cleanup, libs/shared/common/src/index.ts becomes a 10-line file
exporting: constants, filter, interceptors, pipes, validators,
dto, interfaces, decorators, i18n/flat-json.loader. No
behavioural impact on the remaining barrel sub-modules.
AC-8 grep verifies the load-bearing assertion: post-move,
grep -rn "from '@tktspace/shared/data-access-db'" libs/shared/common/src/
returns zero results that mention activities or spheres. (In fact,
it returns zero @tktspace/* results from shared/common/src at all —
verified at the current HEAD that those two helpers are the ONLY
cross-tktspace imports inside shared/common/src.)
D4. resolveTargetApp co-location (supersedes parent ADR §15)
Section titled “D4. resolveTargetApp co-location (supersedes parent ADR §15)”OQ-1 resolution: resolveTargetApp moves alongside applyTargetAppFilter.
The parent ADR §15 (“Critic-pass reconciliation, iteration 2”) originally said:
resolveTargetApp(pure) stays inshared/common/.../helpers/.
That position was correct at the time — the resolver is string-in/string-out
and could plausibly belong to shared/common. This ADR supersedes that
decision on two grounds:
- Concern cohesion. Both helpers are part of the single “target-app
scope resolution” concern. Splitting them across two libraries means
any future change (e.g. when
869dpa3zpdropsDINING_APPor when869dpxbj6collapses activity types) touches two places. Co-location minimises that cost. - Both helpers already import the same module.
resolveTargetAppreadsSphereTargetAppEnum.enumValuesfrom@tktspace/shared/data-access-db;applyTargetAppFilterreadsactivitiesandspheresfrom the same module. The dep set is identical for the new library — no extra transitive coupling cost.
This is explicitly called out so a future reader who lands on parent ADR §15 first does not get confused.
D5. Consumer import-path updates (verified consumers)
Section titled “D5. Consumer import-path updates (verified consumers)”Re-grep verification at HEAD (grep -rn "applyTargetAppFilter\|resolveTargetApp" libs/ apps/ --include="*.ts" | grep -v "helpers/" | grep -v "\.spec\.ts"):
| File | Current line | Current import |
|---|---|---|
libs/features/wallet/src/lib/services/wallet-client.service.ts | 18 | import { applyTargetAppFilter } from '@tktspace/shared/common'; |
libs/features/activities/src/lib/services/activities-client.service.ts | 5 | import { applyTargetAppFilter, resolveTargetApp } from '@tktspace/shared/common'; |
Spec claim confirmed: only these two non-test consumers exist. Other
uses inside the two services are local references (call sites on
wallet-client.service.ts:212, activities-client.service.ts:60,132)
— no further import statements to edit.
After edit:
| File | New line | New import |
|---|---|---|
libs/features/wallet/src/lib/services/wallet-client.service.ts | 18 | import { applyTargetAppFilter } from '@tktspace/shared/target-app'; |
libs/features/activities/src/lib/services/activities-client.service.ts | 5 | import { applyTargetAppFilter, resolveTargetApp } from '@tktspace/shared/target-app'; |
AC-4 enforces that no @tktspace/shared/common import in either feature
mentions either symbol post-MR.
Other @tktspace/shared/common imports in these two features survive
unchanged. wallet-client.controller.ts:4 imports ActiveTargetApp, ActiveUser;
activities-client.controller.ts:5 imports ActiveTargetApp, ActiveUser.
Both decorators stay in shared/common (out of scope per spec —
“No changes to target-app.interceptor.ts, active-target-app.decorator.ts,
or anything in libs/features/auth/”). The decorator’s name overlaps
with the helper concern but they are separate primitives in separate
libs; that is fine.
D6. Test relocation (specs move with subjects; HTTP/e2e stay)
Section titled “D6. Test relocation (specs move with subjects; HTTP/e2e stay)”The two unit .spec.ts files move with their subjects into
libs/shared/target-app/src/lib/. Jest’s resolver picks them up via
the new project’s jest.config.cts (displayName: 'target-app').
The new project gets its own nx test target-app target — invoked by
AC-5.
The HTTP-layer and e2e tests stay in place:
libs/features/activities/src/lib/spec/activities-client.target-app.spec.tsexercisesActivitiesClientService.findAllvia HTTP. It does not import either helper directly — it imports the controller’s mounted routes. No edit needed; AC-6 runsnx test activitiesand expects it green.apps/api-e2e/src/client/wallet-target-app.e2e-spec.tsexercises the/api/client/me/wallet/upcomingendpoint via axios. Same — no helper import. AC-6 runsnx e2e api-e2eand expects it green.libs/features/auth/src/lib/interceptors/target-app.interceptor.spec.ts(the interceptor unit test) does not import either helper. Untouched.
Test relocation is therefore: 2 unit specs move; 3 integration-layer specs stay put.
Migration steps
Section titled “Migration steps”Ordered for the dev agent. Each step is independently verifiable; the build only needs to be green at the end.
-
Scaffold the new library. Create
libs/shared/target-app/with nine files mirroring the modern shape (D1 above). The contents ofproject.json,package.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,eslint.config.mjs,jest.config.ctsare exact copies of the corresponding files underlibs/shared/storage/, with names rewritten:project.json:name→"target-app"project.jsonpaths → replacestoragewithtarget-apppackage.json:name→"@tktspace/shared/target-app"jest.config.cts:displayName→'target-app'jest.config.cts:coverageDirectory→'../../coverage/shared/target-app'
Sanity-check pre-existing copy-paste:
libs/shared/messaging/package.jsontoday has"name": "@tktspace/shared/notifications"(verified — an existing copy-paste artifact in the repo). The dev agent must use the correcttarget-appname and not propagate that drift. -
git mvthe four files. Source → destination, byte-identical contents:libs/shared/common/src/helpers/apply-target-app-filter.ts → libs/shared/target-app/src/lib/apply-target-app-filter.tslibs/shared/common/src/helpers/apply-target-app-filter.spec.ts → libs/shared/target-app/src/lib/apply-target-app-filter.spec.tslibs/shared/common/src/helpers/resolve-target-app.ts → libs/shared/target-app/src/lib/resolve-target-app.tslibs/shared/common/src/helpers/resolve-target-app.spec.ts → libs/shared/target-app/src/lib/resolve-target-app.spec.ts -
Write
src/index.tsfor the new library. Two lines, mirroring the now-deletedhelpers/index.ts:export * from './lib/apply-target-app-filter';export * from './lib/resolve-target-app'; -
Add the path alias to
tsconfig.base.json(undercompilerOptions.paths, alongside the existing five@tktspace/shared/*entries):"@tktspace/shared/target-app": ["libs/shared/target-app/src/index.ts"] -
Update two consumer imports per D5 — change
'@tktspace/shared/common'→'@tktspace/shared/target-app'on the two import statements that mention either helper. -
Delete the empty
helpers/subtree inshared/common:rm libs/shared/common/src/helpers/index.tsrmdir libs/shared/common/src/helpers/- Remove
export * from './helpers';fromlibs/shared/common/src/index.ts(current line 9).
-
Declare the dep in
package.jsonof the new library (see Risks R2 — if@nx/dependency-checkslint flags it). Add"@tktspace/shared/data-access-db": "0.0.1"to the new library’spackage.json:dependenciesalongsidetslib. -
Verify with
nx graph:pnpm exec nx graph --file=/tmp/graph.json && cat /tmp/graph.json | jq '.graph.dependencies["target-app"]'should show one outbound edge todata-access-dband no cycles.pnpm exec nx graph --file=/tmp/graph.json && cat /tmp/graph.json | jq '.graph.dependencies.common'should show no edges todata-access-dbfrom thecommonlib (AC-8 in graph form, complementing the grep form).
-
Run the AC gates in order: AC-5 (
nx test target-app), AC-6 (nx test activities,nx e2e api-e2e), AC-9 (nx build api,nx run-many --target=lint --all), AC-10 (npm run sync:contracts:checkintktspace-backend).
Rollback
Section titled “Rollback”Trivial. The change is pure file moves + import path updates + barrel
removal + alias addition. git revert <merge-commit> cleanly reverses
all of it because:
git mvis captured by git asR100(rename, 100% similarity).- The barrel edit and tsconfig alias edit are single-line diffs.
- No DB migration, no contract change, no feature flag, no caches to invalidate.
Post-revert, the next pnpm install is a no-op (no deps shifted —
tslib is the only common dep and both libs declare it). No consumer
needs to re-run codegen because the surface is unchanged.
R1. Nx cycle re-introduction
Section titled “R1. Nx cycle re-introduction”The parent arc was driven INTO shared/common by an Nx-detected
activities ↔ wallet cycle. Verify the new library does not
re-introduce it:
target-app/must NOT import from@tktspace/features/wallet,@tktspace/features/activities, or any other@tktspace/features/*. The two source files verified at HEAD only import from@tktspace/shared/data-access-dbanddrizzle-orm. Move preserves this.target-app/may safely sit beneath bothfeatures/walletandfeatures/activitiesin the DAG becausedata-access-dbis already a transitive dep of both (verified —activities-client.service.ts:3importsDrizzleService,wallet-client.service.ts:2-14imports the schema barrel).- The pre-existing
activities → walletedge (5 call sites infeatures/activitiesimport fromfeatures/wallet) is unchanged. AC-7 freezes this —activities ↔ walletcycle status must equal baseline.
Mitigation: AC-7 runs nx graph post-MR and inspects the JSON.
Cycle detection is deterministic in Nx; this is a binary pass/fail.
R2. @nx/dependency-checks lint on the new package.json
Section titled “R2. @nx/dependency-checks lint on the new package.json”The modern shared-library template (storage, messaging, queues,
notifications) enables @nx/dependency-checks in eslint.config.mjs
(verified at libs/shared/storage/eslint.config.mjs:6-15). The rule
inspects package.json:dependencies against actual import statements
in source.
The new library imports @tktspace/shared/data-access-db from both
helpers (activities, spheres, SphereTargetAppEnum). To keep lint
green, the new library’s package.json must declare:
"dependencies": { "@tktspace/shared/data-access-db": "0.0.1", "drizzle-orm": "*", "tslib": "^2.3.0"}(verified pattern at libs/shared/messaging/package.json:8-17 which
declares @tktspace/shared/queues because it imports it).
Pre-existing condition note: libs/shared/common/ has NO package.json
at all (verified — directory listing shows only eslint.config.js,
jest.config.ts, project.json, tsconfig.*, src/, README.md).
That is why shared/common was able to import @tktspace/shared/data-access-db
without a declared dep — there was no package.json for the
dependency-check rule to read. The new library, following the modern
template, gains a package.json AND the lint rule, so the dep MUST be
declared. Migration step 7 above captures this.
Note: the older shared/common lib uses eslint.config.js (not .mjs).
The dev agent should NOT use shared/common as a template — use
shared/storage (modern shape, all 9 files, eslint.config.mjs).
R3. Hidden consumers via broad barrel destructuring
Section titled “R3. Hidden consumers via broad barrel destructuring”@tktspace/shared/common is a heavily-used barrel — re-grep verified
that payments, passes, user-profile, wallet controllers, and
many DTOs import from it. The spec claims only two non-test consumers
of applyTargetAppFilter/resolveTargetApp. Re-verified by the grep
above (D5) at HEAD: the only matches in libs/features/* outside of
helpers/ and *.spec.ts are the two services. No import { ... }
clause in any other file destructures either symbol.
Mitigation: AC-4’s grep from '@tktspace/shared/common' over
libs/features/wallet libs/features/activities post-MR catches any
hidden destructuring. AC-9’s nx build api would also fail at
TypeScript compile time if a symbol disappeared from a consumed barrel
(barrel re-exports are tree-shaken-but-named — TS still resolves them
at compile time). Two redundant gates.
Rollout plan
Section titled “Rollout plan”- No feature flag. The change is invisible at runtime — same code, new import path.
- No phased rollout. Single MR, single backend repo, single commit series (scaffold → move → wire → delete → verify).
- No backfill. No DB migration. No contract regen needed (AC-10’s
sync:contracts:checkis the proof gate). - No mobile / web / business / landing release. Surfaces affected =
[]. - MR lands directly on
dev; standard CI pipeline (build + lint + test + sync:contracts:check) is sufficient.
STATUS: READY_FOR_REVIEW