How to override components?
Milan Herda, 07/2024
The Problem
1. Design based on
the current portal
2. Different components for the same functionality
3. Different set of features
Let's start with an obvious approach
'use client';
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
export default function Homepage() {
const hostname = useHostname();
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return (
<>
<HeroBanner />
<SearchForm />
<>
</>
);
}
SOLID principles to the rescue
Single Responsibility Principle
'use client';
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
export default function Homepage() {
const hostname = useHostname();
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return (
<>
<HeroBanner />
<SearchForm />
<>
</>
);
}
Single Responsibility Principle
'use client';
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
export default function Homepage() {
const hostname = useHostname();
const HeroBanner = resolveHeroBanner(hostname)
return (
<>
<HeroBanner />
<SearchForm />
<>
</>
);
}
function resolveHeroBanner(hostname: string) {
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return HeroBanner;
}
Single Responsibility Principle
'use client';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
import resolveHeroBanner from '@local/resolver/application/resolveBanner';
export default function Homepage() {
const hostname = useHostname();
const HeroBanner = resolveHeroBanner(hostname)
return (
<>
<HeroBanner />
<SearchForm />
</>
);
}
Single Responsibility Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
export default function resolveHeroBanner(hostname: string) {
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return HeroBanner;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
export default function resolveHeroBanner(hostname: string) {
let HeroBanner = DefaultHeroBanner;
if (hostname === 'prace.cz') {
HeroBanner = HeroBannerPrace;
} else if (hostname === 'jobs.cz') {
HeroBanner = HeroBannerJobs;
}
return HeroBanner;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'prace.cz': HeroBannerPrace,
'jobs.cz': HeroBannerJobs,
};
export default function resolveHeroBanner(hostname: string) {
if (componentsRegistry[hostname]) {
return componentsRegistry[hostname];
}
return DefaultHeroBanner;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'default': DefaultHeroBanner,
'prace.cz': HeroBannerPrace,
'jobs.cz': HeroBannerJobs,
};
export default function resolveHeroBanner(hostname: string) {
if (componentsRegistry[hostname]) {
return componentsRegistry[hostname];
}
return componentsRegistry.default ?? () => null;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'default': DefaultHeroBanner,
'prace.cz': HeroBannerPrace,
'jobs.cz': HeroBannerJobs,
};
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]) {
return componentsRegistry[hostname];
}
return componentsRegistry.default ?? () => null;
}
Open-Closed Principle
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
const componentsRegistry = {
'default': {
HeroBanner: DefaultHeroBanner,
},
'prace.cz': {
HeroBanner: HeroBannerPrace,
},
'jobs.cz': {
HeroBanner: HeroBannerJobs,
},
};
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]?.[componentName]) {
return componentsRegistry[hostname][componentName];
}
return componentsRegistry.default[componentName] ?? () => null;
}
Open-Closed Principle
import componentsRegistry from '@local/configuration/componentsRegistry';
export default function resolveComponent(hostname: string, componentName: string) {
if (componentsRegistry[hostname]?.[componentName]) {
return componentsRegistry[hostname][componentName];
}
return componentsRegistry.default[componentName] ?? () => null;
}
Things left to solve
- we want to be able resolve also services and parameters, not only components
- TypeScript types
- registry imports all components/services for all supported domains
- we need registry to be domain-specific
- support for lazy loading
- split server components and client components
- all of this will end with the requirement for some form of configuration file
- resolver should be available also in other packages
a Service Container
My approach to the problems was to use
ServiceContainer
- object encapsulating the registry of services (and parameters)
- providing access methods to these services (getService, getParameter, getComponent etc...)
- usable as a Dependency Injection Container (good pattern) or Service Locator (antipattern in most languages)
Implementation
To minimize the work of programmers:
- they can add/edit service in a configuration file and
- registers and containers for each domain will be generated automatically
- server-side and client containers will be generated automatically
- types will be generated automatically (yet TBD)
Configuration & Generation
Configuration
// apps/container-experiment/config/*
{
"components": {
"Demo/Banner": "@local/ui/Banner",
"Demo/ServerComponent": [
"@local/ui/server/ServerComponent",
{
"isServer": true
}
],
"Demo/ClientComponent": [
"@local/ui/client/ClientComponent",
{
"isLazy": true
}
]
},
"services": {},
"parameters": {
"searchEngine": "algolia",
"isUserAccountAvailable": false,
}
}
Generate container from configuration files
// apps/container-experiment/bin/generate-container.sh
#!/bin/bash
PREV_DIR=`pwd`
__DIR__=`dirname ${BASH_SOURCE[0]}`
LOCAL_ENV_FILE=$__DIR__/../.env.local
if [ -f $LOCAL_ENV_FILE ]; then
source $LOCAL_ENV_FILE
fi
if [ -z "$DOMAIN" ]; then
DOMAIN=default
fi
cd $__DIR__/..
echo "Generating containers for $DOMAIN"
yarn container-generator \
--confDir ./config/$DOMAIN/ \
--verbose \
--serverFile=./src/container/serverContainer.ts \
--clientFile=./src/container/clientContainer.ts
cd $PREV_DIR
Generate container from configuration files
// apps/container-experiment/package.json
{
"scripts": {
"dev": "yarn generate:container && next dev",
"build": "yarn generate:container && next build",
"generate:container": "bash bin/generate-container.sh"
},
"dependencies": {
"@almacareer/cyborg.container": "workspace:^"
}
}
TODO
- composing of configuration files in a way where domain-specific files define only overrrides and everything else is taken from default
Generate container from configuration files
// libs/container/src/containerGenerator/index.ts
import commandLineArgs from 'command-line-args';
import chalk from 'chalk';
import { writeFile } from 'node:fs/promises';
import { generateClientContainerCode, generateServerContainerCode } from './generator/generateContainerCode';
import parseConfiguration from './parser/parseConfiguration';
import composeConfiguration from './composer/composeConfiguration';
import findConfigurationFiles from './composer/findConfigurationFiles';
const options = commandLineArgs([
{ name: 'confDir', alias: 'c', type: String, defaultValue: './config' },
{ name: 'serverFile', type: String, defaultValue: 'serverContainer.ts' },
{ name: 'clientFile', type: String, defaultValue: 'clientContainer.ts' },
{ name: 'localConfig', alias: 'l', type: String, defaultValue: './local.services.json' },
{ name: 'verbose', alias: 'v', type: Boolean },
]);
const { confDir, serverFile, clientFile, localConfig } = options;
const files = await findConfigurationFiles(confDir, localConfig);
const configuration = composeConfiguration(files);
const parsedConfiguration = parseConfiguration(configuration.services);
const serverCode = generateServerContainerCode(parsedConfiguration);
const clientCode = generateClientContainerCode(parsedConfiguration);
await writeFile(serverFile, serverCode);
await writeFile(clientFile, clientCode);
Container
Container: types
// libs/container/src/container/application/types.ts
export interface MutableServiceContainer {
setService: <T>(serviceName: string, service: T) => MutableServiceContainer;
}
export interface ServiceLocator {
getService: <T>(serviceName: string) => T | undefined;
}
interface DependencyInjectionContainer extends ServiceLocator, MutableServiceContainer {}
export interface DependencyInjectionContainerProxy extends DependencyInjectionContainer {
wrapContainer: (container: DependencyInjectionContainer) => DependencyInjectionContainerProxy;
}
export default DependencyInjectionContainer;
✏️ TODO:
set/getComponent & set/getParameter methods
Container: context
// libs/container/src/container/infrastructure/react/client/ServiceContainerContext.tsx
import React, { createContext, PropsWithChildren, useContext } from 'react';
import createContainer from '@local/container/application/createContainer';
import DependencyInjectionContainer from '@local/container/application/types';
interface ServiceContainerContextType {
container: DependencyInjectionContainer;
}
const defaultValue: ServiceContainerContextType = {
container: createContainer(),
};
const ServiceContainerContext = createContext(defaultValue);
export function ServiceContainerProvider({
container,
children,
}: PropsWithChildren<{ container: DependencyInjectionContainer }>) {
return (
<ServiceContainerContext.Provider value={{ container }}>
{children}
</ServiceContainerContext.Provider>
);
}
export default ServiceContainerContext;
Container: proxy
import 'server-only';
import createContainer from '@local/container/application/createContainer';
import DependencyInjectionContainer, { DependencyInjectionContainerProxy } from '@local/container/application/types';
export function createProxyContainer(): DependencyInjectionContainerProxy {
let wrappedContainer: DependencyInjectionContainer = createContainer();
return {
setService(serviceName, service) {
wrappedContainer.setService(serviceName, service);
return this;
},
getService(serviceName) {
return wrappedContainer.getService(serviceName);
},
wrapContainer(container) {
wrappedContainer = container;
return this;
},
};
}
const container: DependencyInjectionContainerProxy = createProxyContainer();
export default container;
Why do we need proxy?
App with container
Library
App with real container
Library
Container(proxy)
Generated Server Container
// apps/container-experiment/src/container/serverContainer.ts
import 'server-only';
import DependencyInjectionContainer from '@almacareer/cyborg.container/container/application/types';
import containerProxy from '@almacareer/cyborg.container/container/infrastructure/react/server';
export function createContainer(): DependencyInjectionContainer {
// ...
}
const container = containerProxy.wrapContainer(createContainer());
export default container;
Generated Client Container
import DependencyInjectionContainer from '@almacareer/cyborg.container/container/application/types';
import DemoBannerc331b7bc7ac84527bf8f4f9d2ae1d20e from '@local/ui/Banner';
export function createContainer(): DependencyInjectionContainer {
const services: Record<string, any> = {
'Demo/Banner': DemoBannerc331b7bc7ac84527bf8f4f9d2ae1d20e,
};
const lazyLoadedServices: Record<string, boolean> = {};
const container: DependencyInjectionContainer = {
setService(serviceName, service) {
services[serviceName] = service;
return this;
},
getService(serviceName) {
switch (serviceName) {
// ...
default:
return services[serviceName];
}
},
};
return container;
}
const container = createContainer();
export default container;
Generated Client Container
import DependencyInjectionContainer from '@almacareer/cyborg.container/container/application/types';
import dynamic from 'next/dynamic';
import DemoBannerc331b7bc7ac84527bf8f4f9d2ae1d20e from '@local/ui/Banner';
export function createContainer(): DependencyInjectionContainer {
//...
const lazyLoadedServices: Record<string, boolean> = {};
const container: DependencyInjectionContainer = {
setService(serviceName, service) { /*...*/ },
getService(serviceName) {
switch (serviceName) {
case 'Demo/ClientComponent':
if (!lazyLoadedServices[serviceName]) {
const service = dynamic(() => import('@local/ui/client/ClientComponent'));
services[serviceName] = service;
lazyLoadedServices[serviceName] = true;
}
return services[serviceName];
default:
return services[serviceName];
}
},
};
return container;
}
const container = createContainer();
export default container;
layout.tsx
// apps/container-experiment/src/app/layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css" />
</head>
<body>
<ContainerProvider>
<Navigation />
<main className="px-2 pt-2">{children}</main>
</ContainerProvider>
</body>
</html>
);
}
ContainerProvider.tsx
// apps/container-experiment/src/container/ui/ContainerProvider.tsx
'use client';
import { PropsWithChildren } from 'react';
import {
ServiceContainerProvider
} from '@almacareer/cyborg.container/container/infrastructure/react/client/ServiceContainerContext';
import container from '@local/container/clientContainer';
export default function ContainerProvider({ children }: PropsWithChildren) {
return (
<ServiceContainerProvider container={container}>
{children}
</ServiceContainerProvider>
);
}
Usage
From application, server-side
// apps/container-experiment/src/../SomeComponent.tsx
import container from '@local/container/serverContainer';
import { ComponentType } from 'react';
export default function SomeComponent() {
const ServerComponent = container.getService<ComponentType>('Demo/ServerComponent');
const ClientComponent = container.getService<ComponentType>('Demo/ClientComponent');
return (
<>
<div>
<div>
{ServerComponent ? <ServerComponent /> : null}
</div>
<div>
{ClientComponent ? <ClientComponent /> : null}
</div>
</div>
</>
);
}
From library, server-side
// libs/search/src/ui/List.tsx
import React, { ComponentType } from 'react';
import container from '@almacareer/cyborg.container/container/infrastructure/react/server';
import Item from './Item';
function NullComponent() {
return null;
}
export default function List() {
const items = ['Programmer', 'Product Manager', 'Delivery Manager', 'CEO', 'UX designer'];
const HotOffers = container.getService<ComponentType>('Search/HotOffers') ?? NullComponent;
return (
<div>
<HotOffers />
<div className="message is-info">
<div className="message-body">
This list comes from package <code>@almacareer/cyborg.search</code> and it is server component.
</div>
</div>
{items.map((item) => {
return <Item key={`item-${item}`} title={item} />;
})}
</div>
);
}
From application, client-side
// apps/container-experiment/src/../SomeComponent.tsx
'use client';
import { ComponentType } from 'react';
import {
useService
} from '@almacareer/cyborg.container/container/infrastructure/react/client/ServiceContainerContext';
export default function ClientPage() {
const ClientComponent = useService<ComponentType>('Demo/ClientComponent');
return (
<>
<div>
{ClientComponent ? <ClientComponent /> : null}
</div>
</>
);
}
From library, client-side
// libs/search/src/ui/HotOffers.tsx
'use client';
import React from 'react';
import Item from './Item';
import {
useService
} from '@almacareer/cyborg.container/container/infrastructure/react/client/ServiceContainerContext';
export default function HotOffers() {
const items = ['Hot offer 1', 'Hot offer 2'];
const ClientComponent = useService<ComponentType>('Demo/ClientComponent') ?? NullComponent;
return (
<div>
<div className="message is-info">
<div className="message-body">
This Hot Offers list comes from package <code>@almacareer/cyborg.search</code>
and it is client component
</div>
</div>
{items.map((item) => {
return <Item key={`item-${item}`} title={item} bgVariant="danger" />;
})}
<ClientComponent />
</div>
);
}
From | Import | Usage |
---|---|---|
application, server | @local/serverContainer | container.getService |
library, server | @container/server | container.getService |
application, server | @container/Context | useService |
library, client | @container/Context | useService |
Summary
Implications
- programmers must know their server/client context
- server-only services must be tagged in configuration
- configuration is explicit
- in order to override a component, we need to change the configuration (json files)
- while it is easy to make a typo, it is hard to accidentally override something we are not aware of
- generated container can tell programmers, what component are being used
TODOs:
- distinct between services, components and parameters in a registry (custom methods)
- always use "default" services folder and domain-specific folders will provide only overrides
- find a way to serve multiple domains on one running instance
- generate a container for each one
- add a switching layer
- generate types from configuration
Questions?
How to override components
By Milan Herda
How to override components
- 94