Modular mobile architecture is easy to describe in a diagram and surprisingly easy to erode in production.
A service begins with two approved host capabilities, then adds direct profile access for convenience. A supposedly independent module cannot launch unless another module has already loaded. A low-risk campaign starts initiating payments through an interface that was never designed for transaction assurance. The repository still contains separate folders, but the operating boundary has disappeared.

This article models three delivery zones in TypeScript:
-
native: high-consequence capabilities owned by the host application; -
modular: bounded services delivered through a controlled runtime; -
external: web or separate-app journeys reached through an approved handoff.
The goal is not to make an algorithm decide architecture. The goal is to turn assumptions into reviewable data and reject configurations that violate known boundaries.
Define the delivery vocabulary
Start with a small set of terms shared by product, mobile, security, and platform teams.
export type DeliveryZone = "native" | "modular" | "external";
export type Consequence = "low" | "moderate" | "high" | "critical";
export type Lifecycle =
| "experimental"
| "seasonal"
| "persistent"
| "retiring";
export type HostCapability =
| "identity.basic"
| "identity.strong_auth"
| "payments.initiate"
| "payments.confirm"
| "files.select"
| "camera.capture"
| "location.approximate"
| "notifications.request"
| "analytics.event";
Keep capabilities task-oriented. A capability named user.everything or native.bridge hides the authority being granted. Narrow names improve review, logging, and later deprecation.
The list should represent what the host is willing to support as a product. An interface that exists only for one module may be a valid exception, but it should not silently become part of the general platform contract.
Describe each customer service
A service record should contain enough information to discuss its boundary without reading implementation code.
export type ServiceDefinition = {
id: string;
displayName: string;
owner: string;
zone: DeliveryZone;
consequence: Consequence;
lifecycle: Lifecycle;
customerTask: string;
audience: {
markets: string[];
estimatedReachPercent: number;
};
capabilities: HostCapability[];
dependencies: string[];
independentlySuspendable: boolean;
fallback?: {
kind: "native_route" | "https_url";
destination: string;
};
};
The customerTask is important. It should describe a coherent outcome such as “book a branch appointment” or “submit eligibility documents.” A technical fragment such as “render offer banner” may belong inside another service rather than becoming its own independently operated module.
The owner must resolve to a maintained team or service identity. A person’s email address is likely to become stale. Connect ownership to the organisation’s current service catalogue and escalation system.
Create explicit zone policy
Policy should describe the normal boundaries. Exceptions can then be identified and reviewed deliberately.
type ZonePolicy = {
allowedCapabilities: ReadonlySet<HostCapability>;
maximumConsequence: Consequence;
requireFallback: boolean;
requireIndependentSuspension: boolean;
maximumReachPercent?: number;
};
const consequenceRank: Record<Consequence, number> = {
low: 0,
moderate: 1,
high: 2,
critical: 3,
};
export const zonePolicies: Record<DeliveryZone, ZonePolicy> = {
native: {
allowedCapabilities: new Set<HostCapability>([
"identity.basic",
"identity.strong_auth",
"payments.initiate",
"payments.confirm",
"files.select",
"camera.capture",
"location.approximate",
"notifications.request",
"analytics.event",
]),
maximumConsequence: "critical",
requireFallback: false,
requireIndependentSuspension: false,
},
modular: {
allowedCapabilities: new Set<HostCapability>([
"identity.basic",
"payments.initiate",
"files.select",
"camera.capture",
"location.approximate",
"notifications.request",
"analytics.event",
]),
maximumConsequence: "high",
requireFallback: true,
requireIndependentSuspension: true,
},
external: {
allowedCapabilities: new Set<HostCapability>([
"analytics.event",
]),
maximumConsequence: "high",
requireFallback: false,
requireIndependentSuspension: false,
},
};
This example keeps strong authentication and payment confirmation inside the native zone. A modular service may request a host-controlled payment initiation, while the host owns the final confirmation surface. Your policy will depend on industry, architecture, distribution rules, and threat model.
Avoid treating maximumConsequence as a complete risk decision. A high-consequence modular service may be acceptable with stronger controls, or policy may prohibit it entirely. The field makes the assumption visible so reviewers can challenge it.
Return findings instead of one score
Architecture decisions do not reduce well to a single number. A validator should return specific findings with severity and evidence.
export type Finding = {
code: string;
severity: "warning" | "error";
message: string;
};
export function validateService(
service: ServiceDefinition,
allServices: ReadonlyMap<string, ServiceDefinition>,
): Finding[] {
const findings: Finding[] = [];
const policy = zonePolicies[service.zone];
if (!service.owner.trim()) {
findings.push({
code: "OWNER_MISSING",
severity: "error",
message: "The service needs an accountable owner.",
});
}
if (!service.customerTask.trim()) {
findings.push({
code: "TASK_MISSING",
severity: "error",
message: "Describe the complete customer task.",
});
}
if (
consequenceRank[service.consequence] >
consequenceRank[policy.maximumConsequence]
) {
findings.push({
code: "CONSEQUENCE_EXCEEDS_ZONE",
severity: "error",
message: `${service.consequence} consequence exceeds ${service.zone} policy.`,
});
}
for (const capability of service.capabilities) {
if (!policy.allowedCapabilities.has(capability)) {
findings.push({
code: "CAPABILITY_NOT_ALLOWED",
severity: "error",
message: `${capability} is not allowed in the ${service.zone} zone.`,
});
}
}
if (policy.requireFallback && !service.fallback) {
findings.push({
code: "FALLBACK_REQUIRED",
severity: "error",
message: "This delivery zone requires a maintained fallback.",
});
}
if (
policy.requireIndependentSuspension &&
!service.independentlySuspendable
) {
findings.push({
code: "SUSPENSION_REQUIRED",
severity: "error",
message: "The service must be independently suspendable.",
});
}
if (
policy.maximumReachPercent !== undefined &&
service.audience.estimatedReachPercent > policy.maximumReachPercent
) {
findings.push({
code: "REACH_REVIEW_REQUIRED",
severity: "warning",
message: "Expected customer reach exceeds normal zone policy.",
});
}
for (const dependencyId of service.dependencies) {
if (!allServices.has(dependencyId)) {
findings.push({
code: "DEPENDENCY_UNKNOWN",
severity: "error",
message: `Unknown dependency: ${dependencyId}`,
});
}
}
return findings;
}
Run this validation when a definition is proposed, when capabilities change, and before publication. A service that passed review six months ago should not retain approval after its consequence, reach, or dependencies have materially changed.
Detect dependency cycles
Independent services should not form a hidden release cluster. A cycle such as rewards → profile → offers → rewards is a signal that boundaries or interfaces need review.
export function findDependencyCycles(
services: ReadonlyMap<string, ServiceDefinition>,
): string[][] {
const visiting = new Set<string>();
const visited = new Set<string>();
const path: string[] = [];
const cycles: string[][] = [];
function visit(id: string): void {
if (visiting.has(id)) {
const start = path.indexOf(id);
cycles.push([...path.slice(start), id]);
return;
}
if (visited.has(id)) return;
visiting.add(id);
path.push(id);
for (const dependency of services.get(id)?.dependencies ?? []) {
if (services.has(dependency)) visit(dependency);
}
path.pop();
visiting.delete(id);
visited.add(id);
}
for (const id of services.keys()) visit(id);
return cycles;
}
A dependency is not always wrong. The question is whether it uses a stable, owned contract and whether the dependent service has a defined response when the dependency is unavailable.
Do not let modules call one another through private runtime handles. Route service-to-service interactions through reviewed host capabilities or backend APIs with authentication, timeouts, observability, and failure behaviour.
Validate a real definition
Here is a bounded appointment service:
export const appointments: ServiceDefinition = {
id: "support.appointments",
displayName: "Branch appointments",
owner: "customer-operations",
zone: "modular",
consequence: "moderate",
lifecycle: "persistent",
customerTask: "Book, change, or cancel a branch appointment",
audience: {
markets: ["GB", "SG"],
estimatedReachPercent: 18,
},
capabilities: ["identity.basic", "analytics.event"],
dependencies: [],
independentlySuspendable: true,
fallback: {
kind: "https_url",
destination: "https://services.example.com/appointments",
},
};
The definition is understandable to more than the runtime team. Product can review the customer task and reach. Security can inspect capabilities and consequence. Operations can check ownership, suspension, and fallback. Mobile engineers can map the zone to the correct renderer.
The URL still requires hostname allowlisting and validation. Configuration should never turn the host into an arbitrary web launcher.

Test controls and architecture assumptions
Use unit tests to keep the policy enforceable:
import { describe, expect, it } from "vitest";
import {
appointments,
findDependencyCycles,
validateService,
} from "./boundaries";
describe("delivery boundary policy", () => {
it("accepts a bounded modular service", () => {
const services = new Map([[appointments.id, appointments]]);
expect(validateService(appointments, services)).toEqual([]);
});
it("keeps payment confirmation in the native zone", () => {
const invalid = {
...appointments,
capabilities: ["payments.confirm" as const],
};
const services = new Map([[invalid.id, invalid]]);
expect(validateService(invalid, services)).toContainEqual(
expect.objectContaining({ code: "CAPABILITY_NOT_ALLOWED" }),
);
});
it("requires a fallback for modular delivery", () => {
const invalid = { ...appointments, fallback: undefined };
const services = new Map([[invalid.id, invalid]]);
expect(validateService(invalid, services)).toContainEqual(
expect.objectContaining({ code: "FALLBACK_REQUIRED" }),
);
});
it("reports dependency cycles", () => {
const first = { ...appointments, id: "first", dependencies: ["second"] };
const second = { ...appointments, id: "second", dependencies: ["first"] };
const services = new Map([
[first.id, first],
[second.id, second],
]);
expect(findDependencyCycles(services)).toEqual([
["first", "second", "first"],
]);
});
});
Add tests for duplicate IDs, malformed destinations, unknown markets, owner resolution, forbidden dependency directions, percentage bounds, unsupported lifecycle transitions, and manifest signatures.
Policy tests should use representative services from the real portfolio. A theoretical rule often appears reasonable until a team tries to describe an existing high-volume or regulated journey.
Keep exceptions visible
A policy model should allow exceptions without making them invisible. Store the approving authority, reason, expiry date, compensating controls, and review ticket next to the exception.
An expiry date matters because temporary exceptions have a habit of becoming permanent platform behaviour. Block publication after expiry unless the exception is reviewed again.
Report exceptions by capability and owner. If many services require the same exception, the platform may be missing a supported capability. If one team accumulates unrelated exceptions, its service boundaries may need redesign.
Make boundary review part of delivery
A service definition has limited value if it is written once for an architecture meeting and never checked again. Put the definition in version control near the code that implements the service, while keeping the organisation-wide policy in an independently owned package or validation service.
The pull request should show the policy impact of each change. Adding a capability, expanding to another market, increasing expected reach, changing consequence, or introducing a dependency deserves more attention than editing descriptive text. A CI job can compare the current and proposed definitions and request the appropriate reviewers based on those fields.
Avoid using repository approval as the only control. Publication should revalidate the signed definition against the current platform policy. A service may have passed CI while the host later retired a capability, raised a minimum runtime version, or suspended a dependency. The launch gateway should also verify that the definition being executed is active and matches the approved digest.
Create a small review record containing:
- the definition version and source revision;
- validation findings and acknowledged warnings;
- required security, product, or compliance approvals;
- evidence supporting consequence and customer-reach estimates;
- rollout limits and fallback verification;
- the next review date.
This record does not need to become a heavyweight committee document. Its purpose is to preserve why a service was assigned to a zone and which assumptions made that placement acceptable.
Review timing should follow change and consequence. A persistent low-risk information service may need review only after material changes. A payment-adjacent module may require periodic reassessment even when its code is stable because regulations, fraud patterns, dependencies, and host capabilities can change around it.
Track policy drift at portfolio level. Useful measures include services with expired ownership, modules using deprecated capabilities, definitions without tested fallbacks, dependency cycles, open exceptions, and services that have not been used for a defined period. These measures indicate whether the platform is preserving deliberate boundaries as it grows.
The review can also move a service between zones. An experimental web journey may become a modular service after demand is demonstrated. A modular journey that becomes central, high reach, and tightly coupled to sensitive controls may move into the native core. A declining service can be separated and retired. Zone assignment describes the current operating model; it is not a permanent identity.
Connect definitions to runtime enforcement
Static validation is only the first layer. The runtime must enforce the same capability set when the service executes. The gateway should verify a signed, active definition; confirm host compatibility and market eligibility; and issue a short-lived launch context.
Telemetry should record service ID, definition version, zone, capabilities granted, route decision, and outcome. Avoid logging personal data or raw authorisation tokens.
An emergency control should suspend one service independently. A separate platform-wide control should disable modular execution if the runtime itself is at risk. Test both paths regularly and maintain the declared fallback.
Definitions also need lifecycle handling. A retiring service should stop new discovery before its data and backend are removed. Existing deep links need a deliberate destination. Support documentation and analytics should identify remaining traffic so the team can complete retirement safely.
Measure independence, not module count
The useful outcome of modular architecture is controlled independence.
A service should be able to change without forcing unrelated releases, use only declared host capabilities, fail within an understood boundary, and reach an accountable owner. Its customer task should remain coherent, and the host should be able to withdraw it without damaging the rest of the app.
TypeScript definitions and validators cannot choose those boundaries for a team. They can expose when the implementation no longer matches the intended design.
That is a better platform signal than the number of modules in a catalogue. A small set of services with real autonomy and enforceable contracts creates more value than a highly fragmented app that still moves as one system.
Top comments (0)