Skip to content

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”

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/common now imports two domain tables (activities, spheres), which is a mild violation of the “no domain coupling” intent for shared/common. Tolerable for now; cleaner solution is to extract a dedicated libs/shared/target-app/ library that depends only on data-access-db and is consumed by both features/activities and features/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.

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:

LibsourceRootoutputPathmain / tsConfig prefix
data-access-dbshared/data-access-db/srcdist/shared/data-access-dbshared/… (no libs/)
storagelibs/shared/storage/srcdist/libs/shared/storagelibs/shared/…
messaginglibs/shared/messaging/srcdist/libs/shared/messaginglibs/shared/…
queueslibs/shared/queues/srcdist/libs/shared/queueslibs/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.ts already imports import { activities, spheres } from '@tktspace/shared/data-access-db'; — already a path-aliased import to the only allowed dependency. No edit.
  • resolve-target-app.ts already imports import { SphereTargetAppEnum } from '@tktspace/shared/data-access-db'; — same. No edit.
  • apply-target-app-filter.spec.ts imports ./apply-target-app-filter (relative sibling). After move, still a relative sibling in the new location. No edit.
  • resolve-target-app.spec.ts same — ./resolve-target-app relative 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.ts
apply-target-app-filter.ts
index.ts
resolve-target-app.spec.ts
resolve-target-app.ts

The 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 in shared/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:

  1. 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 869dpa3zp drops DINING_APP or when 869dpxbj6 collapses activity types) touches two places. Co-location minimises that cost.
  2. Both helpers already import the same module. resolveTargetApp reads SphereTargetAppEnum.enumValues from @tktspace/shared/data-access-db; applyTargetAppFilter reads activities and spheres from 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"):

FileCurrent lineCurrent import
libs/features/wallet/src/lib/services/wallet-client.service.ts18import { applyTargetAppFilter } from '@tktspace/shared/common';
libs/features/activities/src/lib/services/activities-client.service.ts5import { 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:

FileNew lineNew import
libs/features/wallet/src/lib/services/wallet-client.service.ts18import { applyTargetAppFilter } from '@tktspace/shared/target-app';
libs/features/activities/src/lib/services/activities-client.service.ts5import { 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.ts exercises ActivitiesClientService.findAll via HTTP. It does not import either helper directly — it imports the controller’s mounted routes. No edit needed; AC-6 runs nx test activities and expects it green.
  • apps/api-e2e/src/client/wallet-target-app.e2e-spec.ts exercises the /api/client/me/wallet/upcoming endpoint via axios. Same — no helper import. AC-6 runs nx e2e api-e2e and 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.

Ordered for the dev agent. Each step is independently verifiable; the build only needs to be green at the end.

  1. Scaffold the new library. Create libs/shared/target-app/ with nine files mirroring the modern shape (D1 above). The contents of project.json, package.json, tsconfig.json, tsconfig.lib.json, tsconfig.spec.json, eslint.config.mjs, jest.config.cts are exact copies of the corresponding files under libs/shared/storage/, with names rewritten:

    • project.json:name"target-app"
    • project.json paths → replace storage with target-app
    • package.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.json today has "name": "@tktspace/shared/notifications" (verified — an existing copy-paste artifact in the repo). The dev agent must use the correct target-app name and not propagate that drift.

  2. git mv the 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.ts
    libs/shared/common/src/helpers/apply-target-app-filter.spec.ts → libs/shared/target-app/src/lib/apply-target-app-filter.spec.ts
    libs/shared/common/src/helpers/resolve-target-app.ts → libs/shared/target-app/src/lib/resolve-target-app.ts
    libs/shared/common/src/helpers/resolve-target-app.spec.ts → libs/shared/target-app/src/lib/resolve-target-app.spec.ts
  3. Write src/index.ts for the new library. Two lines, mirroring the now-deleted helpers/index.ts:

    export * from './lib/apply-target-app-filter';
    export * from './lib/resolve-target-app';
  4. Add the path alias to tsconfig.base.json (under compilerOptions.paths, alongside the existing five @tktspace/shared/* entries):

    "@tktspace/shared/target-app": ["libs/shared/target-app/src/index.ts"]
  5. Update two consumer imports per D5 — change '@tktspace/shared/common''@tktspace/shared/target-app' on the two import statements that mention either helper.

  6. Delete the empty helpers/ subtree in shared/common:

    • rm libs/shared/common/src/helpers/index.ts
    • rmdir libs/shared/common/src/helpers/
    • Remove export * from './helpers'; from libs/shared/common/src/index.ts (current line 9).
  7. Declare the dep in package.json of the new library (see Risks R2 — if @nx/dependency-checks lint flags it). Add "@tktspace/shared/data-access-db": "0.0.1" to the new library’s package.json:dependencies alongside tslib.

  8. 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 to data-access-db and no cycles.
    • pnpm exec nx graph --file=/tmp/graph.json && cat /tmp/graph.json | jq '.graph.dependencies.common' should show no edges to data-access-db from the common lib (AC-8 in graph form, complementing the grep form).
  9. 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:check in tktspace-backend).

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 mv is captured by git as R100 (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.

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-db and drizzle-orm. Move preserves this.
  • target-app/ may safely sit beneath both features/wallet and features/activities in the DAG because data-access-db is already a transitive dep of both (verified — activities-client.service.ts:3 imports DrizzleService, wallet-client.service.ts:2-14 imports the schema barrel).
  • The pre-existing activities → wallet edge (5 call sites in features/activities import from features/wallet) is unchanged. AC-7 freezes this — activities ↔ wallet cycle 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.

  • 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:check is 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