Develop Once, Deploy Many: An Internationalisation Software Pattern 🌎

Jason Conway-Williams
12 min read6 days ago

--

In this blog post, I explore a software architecture pattern that enables teams to build a single codebase capable of handling country-specific and locale-specific configurations and business logic. This approach empowers developers to “develop once, deploy many,” ensuring that a single application can be customised seamlessly for different deployment regions.

In today’s global market, software projects often need to operate across different countries and regions. Internationalisation (i18n) is the process of designing and preparing applications so that they can be easily adapted to various languages, regions, and cultures without requiring engineering changes. By centralising configuration and business logic in a single codebase, teams can implement locale-specific overrides that tailor functionality to the needs of each country. This approach not only reduces duplication of effort but also enables teams to “develop once, deploy many” — building a single project that can be configured and customised at deployment time for various locales.

Benefits of Internationalisation

Internationalisation offers several advantages:

  • Single Codebase, Multiple Deployments: A unified project allows teams to maintain one set of core business logic and configuration files, reducing maintenance overhead.
  • Locale-Specific Overrides: Developers can override default configurations and logic for specific countries. For instance, a default configuration applicable to all locales can be overridden by country-specific settings when needed.
  • Faster Time-to-Market: Since the same project is used across multiple regions, adding a new locale often only requires providing localized configuration and any country-specific business rules.
  • Consistent User Experience: By centralising core logic and only modifying locale-specific pieces, teams can ensure a consistent user experience while still accommodating local differences.

Implementing Internationalisation with TypeScript Path Aliases

A big enabler of this internationalisation pattern is TypeScript path aliases. They make it easy to swap out configurations and logic based on the deployment region. Here’s how it works:

  • Default Files: These live in a global directory (e.g., src/global or infra/global) and contain the standard configuration and business logic that applies everywhere.
  • Country-Specific Overrides: When deploying to a specific country, the system looks for corresponding files in a country-specific directory (e.g., src/us for the U.S., src/gb for the U.K.). If found, these files replace the defaults where needed.

Example

Let’s say your project has a global config file at src/global/config/config.ts. By default, this is used for all deployments. But if a specific region needs a different configuration, you can add a country-specific version with the same file path, just inside the country’s directory—for example: src/gb/config/config.ts . When deploying to the UK, this override file takes precedence over the global one.

Two Simple Rules

To ensure this works smoothly, keep these rules in mind:

  1. It only works with TypeScript module resolution — no relative file paths or manual path operations.
  2. The override file must mirror the global file’s structure and name.
  • For example, you can’t override src/global/config/config.ts with something like src/uk/service-config/config.ts.
  • The override file must have the same name and follow the same directory structure as the original.

Stick to these rules, and you’ll have a clean, scalable way to manage regional configurations in your project!

Dynamic Module Resolution via tsconfig path aliases

The tsconfig-paths package is used to dynamically configure module path aliases, making it easy to manage country-specific overrides. In tsconfig.json, the paths are set up so that when a module is imported using an alias like @src/*, the resolver first looks in a country-specific directory (determined by an environment variable like COUNTRY). If no matching file is found, it falls back to the global directory.

For example, your tsconfig.json might look like this:

   {
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@src/*": [
"src/gb/*",
"src/global/*"
],
"@infra/*": [
"infra/gb/*",
"infra/global/*"
]
}
}
}

We use a custom script, generate-ts-config.js, to dynamically create the tsconfig.json file before deployment. This script runs before the project is built, tested, or deployed, ensuring that the path alias configuration is set based on the COUNTRY environment variable.

For example, we include generate-ts-config.js as a post-install, pre-deployment, and pre-test task in the project's package.json, so the correct tsconfig.json is generated automatically during the setup process.

#!/usr/bin/env node
/* eslint-disable n/no-unpublished-require */
/* eslint-disable quotes */
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable-next-line n/shebang */
const fs = require('fs');
const path = require('path');
const envConfig = require('dotenv').config();

if (!envConfig.parsed.COUNTRY) {
throw Error(
'Cannot generate dynamic paths. Please provide a COUNTRY environment variable.'
);
}

const COUNTRY = envConfig.parsed.COUNTRY;
const globalPath = path.join(__dirname, '../src/global');
const srcDirs = fs.readdirSync(globalPath).filter(file => fs.lstatSync(`${globalPath}/${file}`).isDirectory());

const paths = {
'@infra/*': Array.from(
new Set([`infra/${COUNTRY}/*`, `infra/global/*`])
),
}

srcDirs.forEach(dir => {
paths[`@${dir}/*`] = Array.from(
new Set([`src/${COUNTRY}/${dir}/*`, `src/global/${dir}/*`])
)
paths[`@${dir}`] = Array.from(
new Set([`src/${COUNTRY}/${dir}`, `src/global/${dir}`])
)
});

const config = {
extends: './node_modules/gts/tsconfig-google.json',
compilerOptions: {
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"esModuleInterop": true,
"noUnusedParameters": false,
"sourceMap": true,
"resolveJsonModule": true,
"inlineSourceMap": false,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"typeRoots": [
"./node_modules/@types"
],
baseUrl: '.',
paths,
},
exclude: ['node_modules', 'cdk.out'],
};

fs.writeFileSync(
path.join(__dirname, '..', 'tsconfig.json'),
JSON.stringify(config, null, 4)
);

console.log('Created ts-config.json file');

The generate-ts-config.js script leverages the dotenv module to load the COUNTRY environment variable, determining the deployment region. It then scans all directories within src/global and generates a tsconfig.json file with the necessary path aliases. These aliases include both the country-specific directory and a fallback to the global directory, ensuring smooth internationalization.

Additionally, the generated tsconfig.json extends gts/tsconfig-google.json, incorporating all required path mappings to support the project's structure. The final dynamically created tsconfig.json file would look like this:

{
"extends": "./node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"esModuleInterop": true,
"noUnusedParameters": false,
"sourceMap": true,
"resolveJsonModule": true,
"inlineSourceMap": false,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"typeRoots": [
"./node_modules/@types"
],
"baseUrl": ".",
"paths": {
"@infra/*": [
"infra/gb/*",
"infra/global/*"
],
"@adapters/*": [
"src/gb/adapters/*",
"src/global/adapters/*"
],
"@adapters": [
"src/gb/adapters",
"src/global/adapters"
],
"@config/*": [
"src/gb/config/*",
"src/global/config/*"
],
"@config": [
"src/gb/config",
"src/global/config"
],
"@constants/*": [
"src/gb/constants/*",
"src/global/constants/*"
],
"@constants": [
"src/gb/constants",
"src/global/constants"
],
"@dto/*": [
"src/gb/dto/*",
"src/global/dto/*"
],
"@dto": [
"src/gb/dto",
"src/global/dto"
],
"@errors/*": [
"src/gb/errors/*",
"src/global/errors/*"
],
"@errors": [
"src/gb/errors",
"src/global/errors"
],
"@events/*": [
"src/gb/events/*",
"src/global/events/*"
],
"@events": [
"src/gb/events",
"src/global/events"
],
"@models/*": [
"src/gb/models/*",
"src/global/models/*"
],
"@models": [
"src/gb/models",
"src/global/models"
],
"@schemas/*": [
"src/gb/schemas/*",
"src/global/schemas/*"
],
"@schemas": [
"src/gb/schemas",
"src/global/schemas"
],
"@shared/*": [
"src/gb/shared/*",
"src/global/shared/*"
],
"@shared": [
"src/gb/shared",
"src/global/shared"
],
"@use-cases/*": [
"src/gb/use-cases/*",
"src/global/use-cases/*"
],
"@use-cases": [
"src/gb/use-cases",
"src/global/use-cases"
]
}
},
"exclude": [
"node_modules",
"cdk.out"
]
}

Files within a project can now be imported using the available path aliases like so:

import {createOrderUseCase} from '@use-cases/create-order-use-case';

With the country-specific and global fallback path aliases set in tsconfig.json, we can define a default global implementation of create-order-use-case.ts in the global directory while allowing country-specific overrides in their respective directories. For example:

src 
|
| -> global
| | -> use-cases
| | -> create-order-use-case.ts
|
| -> gb
| | -> use-cases
| | -> create-order-use-case.ts

If create-order-use-case.ts isn't found in the country-specific src directory, TypeScript automatically falls back to the global default version.

With this setup, deploying the service to the UK (gb) would use the UK-specific override file. However, if we deploy it to the US (us), the global default version would be used instead—since no country-specific override exists for that region.

Relative File Paths

While module imports leverage path aliases, file system operations — such as those in AWS CDK that typically use the fs or path modules—still require static, relative paths. This is especially important when specifying the handler path for a CDK Node.js Lambda L2 Construct.

...

new njsLambda.NodejsFunction(this, serviceName, {
functionName: serviceName,
entry: path.resolve(
__dirname,
`../../../../../src/handlers/get-order.ts`,
),
memorySize: 1024,
runtime: lambda.Runtime.NODEJS_20_X,
}

...

TypeScript path aliases — and by extension, this internationalization pattern — won’t work in this scenario.

To solve this, we use an entry file approach. For each Lambda handler, we create a static entry file that imports the actual handler using TypeScript path aliases. This ensures compatibility while maintaining flexibility. The directory structure would look like this:

src
| -> entry files
| | -> get-order.ts
|
| -> global
| -> handlers
| -> get-order
| -> get-order.ts

The entry file would export the actual get-order handler like so:

export * from '@handlers/get-order';

We would then reference the path of the entry file when providing the handler path to the CDK Lambda construct.

...

new njsLambda.NodejsFunction(this, serviceName, {
functionName: serviceName,
entry: path.resolve(
__dirname,
`../../../../../src/entry-files/get-order.ts`,
),
memorySize: 1024,
runtime: lambda.Runtime.NODEJS_20_X,
}

...

This workaround enables us to apply the internationalisation pattern even when working with both absolute and relative file paths.

Demonstration: Order API with Country Specific Logic

That’s the core idea behind the internationalisation software architecture pattern. Now, let’s see how it’s implemented in a project.

The example project is an Order Service API that includes customer details in the order record in both the database and API response. It follows a single-table design in DynamoDB, storing both Customer and Order data in the same table.

When an order is created, the customer data is fetched from the database and added to the order record for faster retrieval. Now, I can almost hear the collective gasps from hardcore relational database purists at the thought of duplicating data and storing it in a denormalised state 😆. I’ll cover this in a blog post in the future but for now… data in the order service API stack is stored denormalised.

Now, let’s break down how the internationalisation pattern enhances specific features in this project.

Country Specific Models and Schema

Different countries may require subtle variations in their models and schemas. A common example is how addresses are structured in the US versus the UK.

  • In the US, an address typically includes state and zip code.
  • In the UK, these fields are referred to as county and postcode.

I have decided to use state + zip code as the standard format for the default global schema.

To implement this, we would:

  1. Define the default US-based address schema in the src/global/model, src/global/dto, and src/global/schema directories.
  2. Override these defaults with country-specific variations where needed.

The global address DTO, model and schema files are shown below.

// src/global/dto/address-dto.ts

export interface AddressDTO {
addressLine1: string;
addressLine2?: string;
city: string;
state: string;
zipcode: string;
}
// src/global/models/address.ts

export interface Address {
addressLine1: string;
addressLine2?: string;
city: string;
state: string;
zipcode: string;
}
// src/global/schemas/address-schema.ts

import { ADDRESS_CODE_REGEX } from "@constants/regex";

const addressSchema = {
type: 'object',
properties: {
addressLine1: { type: 'string' },
addressLine2: { type: 'string' },
city: { type: 'string' },
state: { type: 'string' },
zipcode: {
type: 'string',
pattern: ADDRESS_CODE_REGEX
},
},
required: [
'addressLine1',
'city',
'state',
'zipcode',
],
additionalProperties: false,
};

export { addressSchema };

The address model, schema and DTO files would then be imported and used by the equivalent customer files.

// src/global/dto/customer-dto.ts

import { AddressDTO } from "@dto/address-dto";

export type CustomerDTO = {
id?: string;
name: string;
email: string;
address: AddressDTO;
accountManager: string;
createdDateTime?: string;
updatedDateTime?: string;
};
// src/global/models/customer-types.ts

import { Address } from '@models/address';

export type CustomerType = {
readonly id: string;
readonly name: string;
readonly email: string;
readonly accountManager: string;
readonly address: Address;
readonly createdDateTime: Date;
readonly updatedDateTime: Date;
};
// src/global/schemas/customer-schema.ts

import { addressSchema } from "@schemas/address-schema";

const customerSchema = {
type: 'object',
properties: {
id: {type: 'string'},
name: {type: 'string'},
address: addressSchema,
email: {type: 'string'},
accountManager: {type: 'string'},
createdDateTime: {type: 'object', format: 'date-time'},
updatedDateTime: {type: 'object', format: 'date-time'},
},
required: [
'id',
'name',
'address',
'email',
'accountManager',
],
additionalProperties: false,
};

export {customerSchema};

With this setup, setting the COUNTRY environment variable to "us" or "global" deploys the service using the default US-based address schema.

To support the UK version of the customer address schema and service, we need to provide override files that implement the UK-specific address format, like this:

// src/gb/dto/address-dto.ts

import {AddressDTO as orignal} from '../../global/dto/address-dto';

export interface AddressDTO extends Omit<orignal, 'state' | 'zipcode'> {
county: string;
postcode: string;
}
// src/gb/models/address.ts

export interface Address {
addressLine1: string;
addressLine2?: string;
city: string;
county: string;
postcode: string;
}
// src/gb/schemas/address-schema.ts

import { ADDRESS_CODE_REGEX } from "@constants/regex";

const addressSchema = {
type: 'object',
properties: {
addressLine1: { type: 'string' },
addressLine2: { type: 'string' },
city: { type: 'string' },
county: { type: 'string' },
postcode: {
type: 'string',
pattern: ADDRESS_CODE_REGEX,
},
},
required: [
'addressLine1',
'city',
'county',
'postcode',
],
additionalProperties: false,
};

export { addressSchema };

By setting the COUNTRY environment variable to "gb" and deploying the service, we can provide a UK-specific implementation of the Order API. In this version, the address DTO, model, and schema use county and postcode instead of state and zip code.

You’ll notice that the UK versions of the address DTO and model take two different approaches to modifying the address schema:

  1. Model File Approach:
  • The model file defines a completely new Address interface.
  • It mirrors all properties from the global address schema but replaces state and zip code with county and postcode.

2. DTO File Approach:

  • Instead of creating a brand-new DTO, this file imports the global address DTO.
  • It then extends the original DTO, omits state and zip code, and adds county and postcode.

Both methods work fine, but extending the default global version has a key advantage: 👉 Any updates to the original schema automatically reflect in the override, ensuring consistency with minimal maintenance.

Country Specific Properties and Constants

We can also apply country-specific overrides for configuration, constants, and properties.

Take the service name, for example — it would be useful if resource names reflected the country where the service is deployed. We can achieve this by using a props constants file.

The props file defines reusable string constants that are referenced throughout the codebase, improving efficiency and maintainability. Instead of hardcoding the same string value in multiple places, we define it once as a constant. If the value ever needs to change, updating it in one place ensures the change is applied everywhere it’s used.

In the Order Service API, the props file contains constants for stack names, metric parameters, and configuration properties. Suppose we want the service name — used in Lambda environment variables, log output, and metric output — to dynamically reflect the country of deployment.

To achieve this, we define a global props file like so:

// infra/global/constants/props/props.ts

export const SERVICE = 'Global OrderService';
export const SERVICE_CODE = 'odr';
export const DOMAIN = 'ORDER';
export const AUTH = 'auth';
export const STATEFUL = 'stateful';
export const STATELESS = 'stateless';
export const METRICS_NAMESPACE_NAME = 'Global OrderService';

To enable country-specific overrides for props string constants, we create country-specific props files within their respective country directories.

// infra/gb/constants/props/props.ts

export * from '../../../global/constants/props';
export const SERVICE = 'GB OrderService';
export const METRICS_NAMESPACE_NAME = 'GB OrderService';

Of course, if we needed to support translations for countries where English isn’t the primary language, we could easily implement that as well.

export * from '../../../global/constants/props';
export const SERVICE = 'Servicio de pedidos en España';
export const METRICS_NAMESPACE_NAME = 'Servicio de pedidos en España';

The example above is quite basic — we could dynamically construct a string that includes the country name by referencing the COUNTRY environment variable. However, the goal here is simply to demonstrate how this pattern can be applied to string constants, such as error messages, regex patterns, and more.

For instance, we could define custom error objects for validation failures and resource not found errors like this:

// src/global/errors/validation-error/validation-error.ts

import { VALIDATION_ERROR } from "@constants/errors";

export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = VALIDATION_ERROR;
}
}
// src/global/errors/resource-not-found-error/resource-not-found-error.ts

import { RESOURCE_NOT_FOUND_ERROR } from "@constants/errors";

export class ResourceNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = RESOURCE_NOT_FOUND_ERROR;
}
}

You’ll notice that both errors use a string constant for the error name. These constants are defined in the global error constants file for consistency and reusability.

// src/global/constants/errors/errors.ts

export const VALIDATION_ERROR = 'ValidationError';
export const RESOURCE_NOT_FOUND_ERROR = 'ResourceNotFound';

If we need to return a different error message based on the country of deployment — or provide translations for different languages — we can achieve this using the internationalisation pattern with country-specific overrides.

For example, the code below defines error string constants in Spanish, which will be used when the Order Service is deployed to Spain:

// src/es/constants/errors/errors.ts

export const VALIDATION_ERROR = 'Error de validación';
export const RESOURCE_NOT_FOUND_ERROR = 'Recurso no encontrado';

We can apply the same approach to regular expressions.

Earlier, we looked at the address schema, where postcode and zip code validation patterns are defined using the ADDRESS_CODE_REGEX string constant from the regex constants file.

For the default implementation, we define the US zip code regex in the global directory, like this:

// src/global/constants/regex/regex.ts

export const ADDRESS_CODE_REGEX = '^\\d{5}(?:-\\d{4})?$';

We can then use the internationalisation pattern to provide a country-specific override for the UK deployment, ensuring that customer addresses follow the correct UK postcode format.

// src/gb/constants/regex/regex.ts

export const ADDRESS_CODE_REGEX = '^(GIR ?0AA|(?:(?:[A-PR-UWYZ][0-9]{1,2}|[A-PR-UWYZ][A-HK-Y][0-9]{1,2}|[A-PR-UWYZ][0-9][A-HJKSTUW]|[A-PR-UWYZ][A-HK-Y][0-9][ABEHMNPRVWXY]) ?[0-9][ABD-HJLNP-UW-Z]{2}))$';

Similarly, we can provide an override for the Spain deployment, ensuring that customer addresses adhere to the Spanish postcode format.

// src/es/constants/regex/regex.ts

export const ADDRESS_CODE_REGEX = '^\\d{5}$';

Conclusion

This internationalisation pattern empowers development teams to maintain a single, robust codebase that can be tailored to meet country-specific requirements. By leveraging TypeScript path aliases and a structured override mechanism, you can ensure that core business logic and configuration remain centralised while providing flexibility to inject locale-specific differences where needed. The demonstrated Order API is just one example of how this pattern can be applied, paving the way for scalable, multi-country deployments with ease.

By “developing once” and deploying “many”, teams can focus on delivering quality features while ensuring compliance and local customisation — streamlining the path to global reach.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Jason Conway-Williams
Jason Conway-Williams

Written by Jason Conway-Williams

Cloud Solutions Architect at City Electrical Factors (CEF)

No responses yet

Write a response