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