alepha@docs:~/framework/docs/guides/core$
cat 12-extending-a-module.md | pretty
5 min read
Last commit:

#Extending a Module

A module you installed is not a sealed box. An app can replace one of its pages, add pages around it, swap one of its services and change its options, all from its own code, without forking the package or asking it for a hook. This is what lets one product ship as a package and several customer apps each carry their own differences.

Three mechanisms do it, plus the module's own options:

You want to Use Where
Replace a page's component, or any primitive option primitive.override({ ... }) before start()
Add a page of yours under a page of the package parent: this.router.page on your page your router
Put the package's pages under a layout of yours children: () => [...] on your layout your router
Replace a service's behaviour alepha.with({ provide, use }) before the module
Change a setting the package exposes alepha.set(atom, value) on its atom before start()
Replace one component inside a page a slots service, swapped like a service before the module

The rest of this page is one example: a package, then an app that changes it in each of those ways.

#The package

@acme/crm exports a router, a service, an options atom, and the module that groups them:

tsx
 1import { $atom, $inject, $module, Alepha, z } from "alepha"; 2import { $page, NestedView } from "alepha/react/router"; 3import type { FC } from "react"; 4  5export const crmOptions = $atom({ 6  name: "crm.options", 7  schema: z.object({ pageSize: z.integer() }), 8  default: { pageSize: 25 }, 9});10 11export class PricingService {12  quote(amount: number): string {13    return `${amount} EUR`;14  }15}16 17// The components the package lets an app replace, one field each.18export class CrmSlots {19  ClientBadge: FC<{ name: string }> = (props) => <b>{props.name}</b>;20}21 22export class CrmRouter {23  protected readonly alepha = $inject(Alepha);24  protected readonly pricing = $inject(PricingService);25  protected readonly slots = $inject(CrmSlots);26 27  clients = $page({28    path: "/clients",29    component: () =>30      `Clients, ${this.alepha.store.get(crmOptions).pageSize} per page`,31  });32 33  client = $page({34    path: "/clients/:id",35    component: () => {36      const { ClientBadge } = this.slots;37      return (38        <>39          <ClientBadge name="Ada" /> owes {this.pricing.quote(100)}40          <NestedView />41        </>42      );43    },44  });45}46 47export const CrmModule = $module({48  name: "crm",49  services: [CrmRouter, PricingService, CrmSlots],50  atoms: [crmOptions],51});

Nothing here was written for extension except CrmSlots and the <NestedView /> in client. Everything else is an ordinary module.

#Replace a page

Every primitive has override(): it shallow-merges new options into the ones the package declared. On a $page that means its component, its lazy import, its loader, its head: anything $page takes.

tsx
1const alepha = Alepha.create().with(CrmModule);2 3alepha.inject(CrmRouter).clients.override({4  component: () => <AcmeClients />,5});

/clients now renders AcmeClients. The path, the name and every option you did not pass stay the package's, so links to the page keep working.

The same thing as a class, when you would rather keep the changes to one router in one place:

tsx
 1class AcmeCrmRouter extends CrmRouter { 2  constructor() { 3    super(); 4    this.clients.override({ component: () => <AcmeClients /> }); 5  } 6} 7  8const alepha = Alepha.create() 9  .with({ provide: CrmRouter, use: AcmeCrmRouter })10  .with(CrmModule);

override() works on any primitive, not only pages: an $action's options, a $job's schedule, a $hook's event.

#Add a page under one of the package's

Your page names the package's page as its parent. It takes the parent's path as a prefix and renders where the parent puts its <NestedView />:

tsx
1class AcmeRouter {2  protected readonly crm = $inject(CrmRouter);3 4  contracts = $page({5    parent: this.crm.client,6    path: "/contracts",7    component: () => <ClientContracts />,8  });9}

/clients/1/contracts renders the package's client page with ClientContracts inside it.

#Put the package's pages under your layout

The other direction: your layout adopts pages it does not own, with children. See Nested Routing.

tsx
 1class AcmeRouter { 2  protected readonly crm = $inject(CrmRouter); 3  4  shell = $page({ 5    path: "/acme", 6    children: () => [this.crm.clients, this.crm.client], 7    component: () => ( 8      <AcmeShell> 9        <NestedView />10      </AcmeShell>11    ),12  });13}

The package's pages now live at /acme/clients and /acme/clients/:id, inside AcmeShell.

#Swap a service

Substitute the class, and everything in the package that injects PricingService gets yours instead. See Swapping Implementations.

typescript
1class AcmePricingService extends PricingService {2  override quote(amount: number): string {3    return `${amount * 1.2} EUR incl. VAT`;4  }5}6 7const alepha = Alepha.create()8  .with({ provide: PricingService, use: AcmePricingService })9  .with(CrmModule);

Extend the original and override what differs, so the methods you leave alone, and the ones a later version of the package adds, keep working.

#Change an option

A package that exposes its settings as an atom lets you set them without touching a class, any time before start():

typescript
1const alepha = Alepha.create().with(CrmModule);2 3alepha.set(crmOptions, { pageSize: 50 });

A setting read from the environment is changed there instead: see Configurations.

#Replace one component inside a page

override() replaces a page's whole component. It cannot reach inside it and change one button: a component is a function, with nothing to merge into.

A package that wants a part of a page to be replaceable says so, by reading that part from a service. That is CrmSlots above: the page renders this.slots.ClientBadge, so the app swaps the service:

tsx
1class AcmeSlots extends CrmSlots {2  override ClientBadge: FC<{ name: string }> = (props) => (3    <mark>{props.name}</mark>4  );5}6 7const alepha = Alepha.create()8  .with({ provide: CrmSlots, use: AcmeSlots })9  .with(CrmModule);

If the package offers no such service, the options are to replace the whole page with override(), or to ask the package for a slot.

#Limits

  • override() is for before start(). Options are consumed while the app starts (routes are registered, jobs scheduled), so after that it throws Cannot override primitive '...' after Alepha has started. Call it right after registering the module, or in a subclass constructor.
  • override() is a shallow merge. A nested option, such as a $page's schema, is replaced whole, not merged field by field.
  • A substitution comes before the module. alepha.with({ provide, use }) must be recorded before anything builds the original; registering the module builds its services, so a substitution placed after with(CrmModule) throws TooLateSubstitutionError. So does a second substitution of the same service, unless it says optional: true, which keeps the first one: that is how a package declares a default the app may replace.
  • A subclass sees protected members, not private ones. A package meant to be extended declares its members protected.
  • Inner components need a slot. See above: only what the package reads from a service, an atom or an option can be changed from outside.
  • You are overriding a version. An option you replace no longer follows the package: when it changes that page's loader or props in a later release, your component is the one to update.