---
name: ledgerhq-legacy-migration
description: "Move code out of `libs/` or `apps/*/src` into the layers (`shared/`, `domain/`, `features/`) in three steps, addition, deprecation and deletion, and give a new-architecture package what only legacy code provides through injection (ports, thunk `extraArgument`) instead of an import. Use when moving, extracting or porting code out of `libs/` or `apps/*/src`, replacing a legacy module with a new package, deprecating or deleting legacy code, or when a new package seems to need a `libs/` import."
license: "MIT"
metadata:
  decimalai-skill-id: "474d6fad-4bd6-466c-b573-797e7f6fdd04"
  decimalai-version-id: "861ec3bf-2947-41e2-9807-41e2406ef5de"
  decimalai-version: "2"
  decimalai-body-sha256: "eba22960d38d35afeb48ae3efdbd8c31b3f51344c10b8ad11c52889391886cb2"
  decimalai-name: "ledgerhq/legacy-migration"
  decimalai-format: "1"
  decimalai-invocation: "model"
  decimalai-category: "coding"
  decimalai-source: "https://app.decimal.ai/s/ledgerhq-legacy-migration@2/SKILL.md"
---
# Legacy migration

Code leaves `libs/` and `apps/*/src` in three steps: **addition, deprecation, deletion**. Each step
is its own PR and leaves `develop` working. [structure-flow](../structure-flow/SKILL.md) decides
which layer receives the code; this skill covers how it gets there. The layer model and the status
of each `libs/` area live in [the architecture guide](../../../docs/monorepo-architecture.md).

## Boundary rules this relies on

- `shared/`, `domain/` and `features/` never import a `libs/` package (`scope:libs` tag), neither as
  a declared dependency nor as a source import.
- Only `apps/` imports both sides. Legacy code may consume new code as migration glue, never the
  reverse.
- **No new exception.** `BOUNDARY_EXCEPTIONS` in
  [`constraints.js`](../../../tools/nx-plugins/enforce-boundaries/constraints.js) lists the
  existing allowances for a new package to depend on a legacy one, each kept only until that legacy
  package leaves the repo. When a new package needs legacy code, inject it (below); never add an
  entry.
- Never re-export a legacy type from a new-architecture barrel.

`pnpm lint:boundaries` enforces the import rules: it checks the Nx graph against the layer
constraints, scans source imports under `shared/`, `domain/` and `features/` for legacy packages,
and rejects manifests that declare a banned package. Run it before every migration commit.

## 1. Addition

Build the replacement in the lowest valid layer, with its tests, while the legacy code keeps
working. Keep the public names when the new package is a drop-in replacement: callers then switch
by changing an import path.

Example: [#17239](https://github.com/LedgerHQ/ledger-live/pull/17239) added
`@features/platform-feature-flags` as a Redux-backed drop-in replacement for the Context-based
hooks of `@ledgerhq/live-common/featureFlags` (`useFeature`, `useFeatureFlags`, `FeatureToggle`),
reading the slice that `@shared/feature-flags` owns. No caller moved in that PR.

## 2. Deprecation

Point every caller at the replacement and stop new ones from appearing:

- Mark each legacy export `@deprecated`, naming the replacement package and export.
- Migrate the callers. Legacy code that stays may import the new package.
- Ban the old import in the apps with an `eslint/no-restricted-imports` pattern in their
  `.oxlintrc.json`, with `importNamePattern` and a `message` naming the new package. This ban is
  the only real guard: `@deprecated` alone blocks nothing. Both
  [desktop](../../../apps/ledger-live-desktop/oxlint.config.mts) and
  [mobile](../../../apps/ledger-live-mobile/oxlint.config.mts) set `typescript/no-deprecated` to
  `error`, but it is a type-aware rule and their `lint` scripts (`oxlint src ...` in each
  `package.json`) never pass `--type-aware`, so it reports nothing.
- Notify open PRs still using the old path: [impacting-prs](../impacting-prs/SKILL.md).

Example: [#20261](https://github.com/LedgerHQ/ledger-live/pull/20261) moved the currency accessors
to `@domain/entity-currency-crypto` and `@domain/entity-currency-fiat`. Both apps' `.oxlintrc.json`
reject `findCryptoCurrencyById` and its siblings from `@ledgerhq/live-common/currencies` with a
message naming the domain packages. For the JSDoc form, see
[`libs/types-live/src/feature.ts`](../../../libs/types-live/src/feature.ts): "`@deprecated` Moved
to `@shared/feature-flags`".

## 3. Deletion

Delete once nothing imports the legacy code: search the import path and every exported name
separately (static imports, `require`, `jest.mock` factories, string literals). Where the package
runs `knip`, let it confirm the exports are unused.

- Delete the module, its tests, the app bridges that adapted it, and the restricted-import entries
  that named its paths.
- A whole package that must stay published but that no workspace package may depend on goes into
  `BANNED_DEPENDENCIES` in `constraints.js` first; delete the package in a later PR.
- Removing a public export from a package that stays needs a changeset on that package:
  [create-changeset](../create-changeset/SKILL.md). A deleted package cannot appear in a changeset,
  since the changeset check rejects packages that are not in the workspace: the changeset names the
  packages that dropped it, and pending changesets that list it lose that entry
  ([details](references/injection.md#deleting-a-whole-package)).

Example: once both apps used `@features/platform-feature-flags`,
[#18237](https://github.com/LedgerHQ/ledger-live/pull/18237) deleted the live-common `featureFlags`
module and the app feature-flag bridges. `@ledgerhq/errors` followed the package route: banned in
`BANNED_DEPENDENCIES` by [#20280](https://github.com/LedgerHQ/ledger-live/pull/20280), then
dropped from `libs/` by [#20278](https://github.com/LedgerHQ/ledger-live/pull/20278).

## Crossing the frontier with injection

A new package that needs behavior only legacy code has declares the contract itself and receives
the implementation. The app, the only place allowed to import both sides, builds the
implementation from legacy code and hands it over at the composition root.

**Port**: a type in the new package, implemented by the app and passed in.

```ts
// features/flow/flow-contacts-add-address/src/state/ports.ts
export type ContactsCurrencySelectionPort = Readonly<{
  selectCurrency(networkIds: readonly CryptoCurrency["id"][]): Promise<AddAddressCurrencySelection | null>;
}>;
```

Desktop implements it in
[`useContactsCurrencySelectionAdapter.ts`](../../../apps/ledger-live-desktop/src/mvvm/features/Contacts/hooks/useContactsCurrencySelectionAdapter.ts)
on top of the live-common modular drawer, and the Contacts screen view model hands it to the flow.
The flow package never sees `@ledgerhq/live-common`.

**Thunk `extraArgument`**: for thunks and RTK Query endpoints, the contract is a typed slice of the
store's `extraArgument`, built by an `xxxExtra()` function the package exports.

```ts
// apps/ledger-live-desktop/src/state-manager/configureStore.ts
thunk: {
  extraArgument: {
    ...accountDataExtra(accountDataSources(getState, dispatch)),
  },
},
```

`@domain/api-account-data` declares `AccountDataSource`; live-common implements it with
`createFullSyncSource`, and each app's `account-data-setup.ts`
([desktop](../../../apps/ledger-live-desktop/src/config/account-data-setup.ts),
[mobile](../../../apps/ledger-live-mobile/src/config/account-data-setup.ts)) feeds it the legacy accounts
and bridge cache. The domain package reads its router from `extraArgument` and stays legacy-free.

More patterns and their trade-offs: [references/injection.md](references/injection.md).

## Checklist

- [ ] The target layer follows [structure-flow](../structure-flow/SKILL.md).
- [ ] Addition, deprecation and deletion land as separate PRs.
- [ ] No new package imports or declares a `libs/` package; `BOUNDARY_EXCEPTIONS` is unchanged.
- [ ] Legacy behavior reaches new code through a port or `extraArgument` wired in `apps/`.
- [ ] `pnpm lint:boundaries` passes.
