Skip to content

Modular Architecture Preset

The Modular Architecture preset enforces feature-based organization where each module has a public API (index.ts) and internal implementation details are encapsulated.

What is Modular Architecture?

Modular Architecture is a pattern that:

  • Organizes code by features (not technical layers)
  • Enforces public API boundaries through index.ts exports
  • Prevents direct access to module internals
  • Enables team scalability by isolating features
  • Promotes loose coupling between modules

The core principle: Modules communicate only through public APIs.

When to Use This Preset

Use the Modular preset when you have:

  • Large applications with many features
  • Multiple teams working on different features
  • Need for module isolation and clear boundaries
  • Feature-based organization preference
  • Scalability requirements for growing teams

Architecture Diagram

%%{init: {'theme':'base', 'themeVariables': {
  'primaryColor':'#b3d9ff',
  'primaryBorderColor':'#1976d2',
  'secondaryColor':'#b3ffcc',
  'secondaryBorderColor':'#2e7d32',
  'tertiaryColor':'#ffffb3',
  'tertiaryBorderColor':'#f57c00'
}}}%%
graph TB
    subgraph UsersModule["πŸ“ features/users/"]
        UsersPublic["πŸ“„ index.ts
Public API"] UsersInternal["πŸ“ Internal Files
user-service.ts
user-model.ts
user-repository.ts
"] end subgraph OrdersModule["πŸ“ features/orders/"] OrdersPublic["πŸ“„ index.ts
Public API"] OrdersInternal["πŸ“ Internal Files
order-service.ts
order-model.ts
order-repository.ts
"] end subgraph ProductsModule["πŸ“ features/products/"] ProductsPublic["πŸ“„ index.ts
Public API"] ProductsInternal["πŸ“ Internal Files
product-service.ts
product-model.ts
product-repository.ts
"] end subgraph Shared["πŸ“ shared/"] SharedUtils["utils/
logger.ts
config.ts"] SharedTypes["types/
common.ts"] end %% Allowed dependencies (green arrows) %% Modules can only import from other modules' public APIs OrdersInternal --> UsersPublic OrdersInternal --> ProductsPublic OrdersInternal --> Shared UsersInternal --> Shared ProductsInternal --> Shared %% Public APIs can import from their module's internals UsersPublic --> UsersInternal OrdersPublic --> OrdersInternal ProductsPublic --> ProductsInternal style UsersModule fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style OrdersModule fill:#f3e5f5,stroke:#8e24aa,stroke-width:2px style ProductsModule fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px style Shared fill:#fff3e0,stroke:#f57c00,stroke-width:2px linkStyle default stroke:#22c55e,stroke-width:2px

Directory Structure

src/
β”œβ”€β”€ features/ # Feature modules
β”‚ β”œβ”€β”€ users/
β”‚ β”‚ β”œβ”€β”€ index.ts # Public API (exports)
β”‚ β”‚ β”œβ”€β”€ user-service.ts # Internal
β”‚ β”‚ β”œβ”€β”€ user-model.ts # Internal
β”‚ β”‚ β”œβ”€β”€ user-repository.ts # Internal
β”‚ β”‚ └── types.ts # Internal
β”‚ β”œβ”€β”€ orders/
β”‚ β”‚ β”œβ”€β”€ index.ts # Public API
β”‚ β”‚ β”œβ”€β”€ order-service.ts # Internal
β”‚ β”‚ β”œβ”€β”€ order-model.ts # Internal
β”‚ β”‚ └── order-repository.ts # Internal
β”‚ └── products/
β”‚ β”œβ”€β”€ index.ts # Public API
β”‚ β”œβ”€β”€ product-service.ts # Internal
β”‚ └── product-model.ts # Internal
└── shared/ # Shared utilities
β”œβ”€β”€ utils/
β”‚ β”œβ”€β”€ logger.ts
β”‚ └── validation.ts
β”œβ”€β”€ types/
β”‚ └── common.ts
└── config/
└── database.ts

Boundaries Defined

The preset defines three boundaries:

1. Module Public API

  • Pattern: src/features/*/index.ts
  • Tags: module-public, features
  • Purpose: Public API exports for feature modules
  • Visibility: Public

2. Module Internal

  • Pattern: src/features/** (excludes index.ts)
  • Tags: module-internal, features
  • Purpose: Internal implementation files within feature modules
  • Visibility: Private (to the module)

3. Shared

  • Pattern: src/shared/**
  • Tags: shared, common
  • Purpose: Shared utilities, components, and types
  • Visibility: Public (available to all modules)

Key Rules Enforced

Modules Must Import via Public APIs

Modules can only import from other modules through their index.ts public API.

src/features/orders/order-service.ts
// ❌ BAD - Directly importing internal file
import { UserRepository } from '../users/user-repository'
// This violates encapsulation!
// βœ… GOOD - Importing from public API
// src/features/orders/order-service.ts
import { getUserById } from '../users' // From users/index.ts

Public API Exports Only What’s Needed

The index.ts file explicitly exports only the public interface of the module.

src/features/users/index.ts
// βœ… GOOD - Public API with selective exports
export { createUser, getUserById, updateUser } from './user-service'
export type { User, CreateUserInput } from './types'
// Internal functions are NOT exported:
// - UserRepository
// - validateUserEmail (helper)
// - formatUserData (helper)

Shared Code Cannot Import Features

Shared utilities must remain generic and feature-independent.

src/shared/utils/user-helper.ts
// ❌ BAD - Shared importing from feature
import { User } from '../../features/users/types'
// Shared cannot depend on features!
// βœ… GOOD - Shared with generic types
// src/shared/utils/formatter.ts
export function formatName(firstName: string, lastName: string): string {
return `${firstName} ${lastName}`
}
// βœ… GOOD - Shared with generic interface
// src/shared/types/common.ts
export interface Entity {
id: string
createdAt: Date
}
// Features can use/extend these generic types

Within a Module, Import Freely

Files within the same module can import each other directly.

src/features/users/user-service.ts
// βœ… GOOD - Internal files importing each other
import { UserRepository } from './user-repository'
import { validateEmail } from './validators'
import { User } from './types'
export async function createUser(email: string) {
validateEmail(email)
const user = new User(email)
return await UserRepository.save(user)
}

Example Configuration

{
"preset": "@stricture/modular"
}

No additional configuration needed for standard modular structure.

Real Code Example

Public API (index.ts)

src/features/users/index.ts
// This is the ONLY way other modules can interact with this feature
export {
createUser,
getUserById,
getUserByEmail,
updateUser,
deleteUser
} from './user-service'
export type {
User,
CreateUserInput,
UpdateUserInput
} from './types'
// NOT exported (internal only):
// - UserRepository
// - validateUserEmail
// - formatUserData
// - UserModel (database model)

Internal Service

src/features/users/user-service.ts
import { UserRepository } from './user-repository'
import { validateEmail } from './validators'
import { User, CreateUserInput, UpdateUserInput } from './types'
export async function createUser(input: CreateUserInput): Promise<User> {
validateEmail(input.email)
const existing = await UserRepository.findByEmail(input.email)
if (existing) {
throw new Error('User already exists')
}
const user: User = {
id: crypto.randomUUID(),
email: input.email,
name: input.name,
createdAt: new Date()
}
await UserRepository.save(user)
return user
}
export async function getUserById(id: string): Promise<User | null> {
return await UserRepository.findById(id)
}
export async function updateUser(
id: string,
input: UpdateUserInput
): Promise<User> {
const user = await UserRepository.findById(id)
if (!user) {
throw new Error('User not found')
}
const updated = { ...user, ...input }
await UserRepository.save(updated)
return updated
}

Internal Repository

src/features/users/user-repository.ts
import { User } from './types'
import { db } from '../../shared/config/database'
export class UserRepository {
static async save(user: User): Promise<void> {
await db.query(
`INSERT INTO users (id, email, name, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET email = $2, name = $3`,
[user.id, user.email, user.name, user.createdAt]
)
}
static async findById(id: string): Promise<User | null> {
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[id]
)
return result.rows[0] || null
}
static async findByEmail(email: string): Promise<User | null> {
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
)
return result.rows[0] || null
}
}

Another Module Using Public API

src/features/orders/order-service.ts
import { getUserById } from '../users' // From users/index.ts
import { getProductById } from '../products' // From products/index.ts
import { Order, CreateOrderInput } from './types'
export async function createOrder(input: CreateOrderInput): Promise<Order> {
// Validate user exists (using public API)
const user = await getUserById(input.userId)
if (!user) {
throw new Error('User not found')
}
// Validate product exists (using public API)
const product = await getProductById(input.productId)
if (!product) {
throw new Error('Product not found')
}
const order: Order = {
id: crypto.randomUUID(),
userId: input.userId,
productId: input.productId,
quantity: input.quantity,
total: product.price * input.quantity,
createdAt: new Date()
}
await OrderRepository.save(order)
return order
}

Shared Utilities

src/shared/utils/logger.ts
export class Logger {
static info(message: string, data?: any): void {
console.log(`[INFO] ${message}`, data)
}
static error(message: string, error?: Error): void {
console.error(`[ERROR] ${message}`, error)
}
}
src/shared/utils/validation.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function isValidUUID(id: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
}

Common Violations and Fixes

Violation: Direct import of internal file

src/features/orders/order-service.ts
// ❌ BAD
import { UserRepository } from '../users/user-repository'
import { validateEmail } from '../users/validators'
// Violates module encapsulation!

Fix: Use public API or move logic to shared

src/features/orders/order-service.ts
// βœ… GOOD - Option 1: Use public API
import { getUserById } from '../users'
// βœ… GOOD - Option 2: Move to shared if generic
// src/shared/utils/validation.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// src/features/orders/order-service.ts
import { isValidEmail } from '../../shared/utils/validation'

Violation: Shared importing features

src/shared/utils/user-formatter.ts
// ❌ BAD
import { User } from '../../features/users/types'
export function formatUser(user: User): string {
return `${user.name} <${user.email}>`
}

Fix: Use generic types or parameters

src/shared/types/common.ts
// βœ… GOOD - Generic interface in shared
export interface UserLike {
name: string
email: string
}
// src/shared/utils/user-formatter.ts
import { UserLike } from '../types/common'
export function formatUser(user: UserLike): string {
return `${user.name} <${user.email}>`
}
// Features can use it
// src/features/users/user-service.ts
import { formatUser } from '../../shared/utils/user-formatter'
// Works because User matches UserLike structure

Violation: Not exporting through index.ts

src/features/orders/order-service.ts
// ❌ BAD
// Another module directly importing
import { createUser } from '../users/user-service'
// Should import from index.ts instead

Fix: Always import from index.ts

src/features/orders/order-service.ts
// βœ… GOOD
import { createUser } from '../users'
// Imports from users/index.ts

Benefits

  • Scalability: Teams can work on different modules independently
  • Encapsulation: Internal implementation can change without affecting others
  • Clear boundaries: Public API makes module contracts explicit
  • Discoverability: Easy to find what a module offers (check index.ts)
  • Maintainability: Changes are localized to modules

Trade-offs

  • More index.ts files: Need to maintain public API exports
  • Potential duplication: Shared logic needs careful extraction
  • Granularity decisions: Determining module boundaries requires thought

Best Practices

Keep Public APIs Small

Only export what other modules truly need.

src/features/users/index.ts
// βœ… GOOD - Minimal public API
export { createUser, getUserById } from './user-service'
export type { User } from './types'
// ❌ BAD - Exposing too much
export * from './user-service'
export * from './user-repository'
export * from './validators'
// This defeats the purpose of encapsulation!

Use Barrel Exports Wisely

// βœ… GOOD - Explicit exports
export { createUser, getUserById } from './user-service'
// ⚠️ USE CAREFULLY - Barrel export
export * from './user-service'
// Only if you want to export everything from that file

Extract Common Logic to Shared

If logic is used by multiple modules, move it to shared.

src/features/users/validators.ts
// Before: Duplicated in users and orders
export function isValidEmail(email: string) { ... }
// src/features/orders/validators.ts
export function isValidEmail(email: string) { ... }
// After: Extracted to shared
// src/shared/utils/validation.ts
export function isValidEmail(email: string) { ... }

Next Steps