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.tsBoundaries 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.
// β BAD - Directly importing internal fileimport { UserRepository } from '../users/user-repository'// This violates encapsulation!
// β
GOOD - Importing from public API// src/features/orders/order-service.tsimport { getUserById } from '../users' // From users/index.tsPublic API Exports Only Whatβs Needed
The index.ts file explicitly exports only the public interface of the module.
// β
GOOD - Public API with selective exportsexport { 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.
// β BAD - Shared importing from featureimport { User } from '../../features/users/types'// Shared cannot depend on features!
// β
GOOD - Shared with generic types// src/shared/utils/formatter.tsexport function formatName(firstName: string, lastName: string): string { return `${firstName} ${lastName}`}
// β
GOOD - Shared with generic interface// src/shared/types/common.tsexport interface Entity { id: string createdAt: Date}
// Features can use/extend these generic typesWithin a Module, Import Freely
Files within the same module can import each other directly.
// β
GOOD - Internal files importing each otherimport { 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)
// 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
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
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
import { getUserById } from '../users' // From users/index.tsimport { getProductById } from '../products' // From products/index.tsimport { 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
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) }}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
// β BADimport { UserRepository } from '../users/user-repository'import { validateEmail } from '../users/validators'// Violates module encapsulation!Fix: Use public API or move logic to shared
// β
GOOD - Option 1: Use public APIimport { getUserById } from '../users'
// β
GOOD - Option 2: Move to shared if generic// src/shared/utils/validation.tsexport function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)}
// src/features/orders/order-service.tsimport { isValidEmail } from '../../shared/utils/validation'Violation: Shared importing features
// β BADimport { User } from '../../features/users/types'
export function formatUser(user: User): string { return `${user.name} <${user.email}>`}Fix: Use generic types or parameters
// β
GOOD - Generic interface in sharedexport interface UserLike { name: string email: string}
// src/shared/utils/user-formatter.tsimport { UserLike } from '../types/common'
export function formatUser(user: UserLike): string { return `${user.name} <${user.email}>`}
// Features can use it// src/features/users/user-service.tsimport { formatUser } from '../../shared/utils/user-formatter'// Works because User matches UserLike structureViolation: Not exporting through index.ts
// β BAD// Another module directly importingimport { createUser } from '../users/user-service'// Should import from index.ts insteadFix: Always import from index.ts
// β
GOODimport { createUser } from '../users'// Imports from users/index.tsBenefits
- 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.
// β
GOOD - Minimal public APIexport { createUser, getUserById } from './user-service'export type { User } from './types'
// β BAD - Exposing too muchexport * from './user-service'export * from './user-repository'export * from './validators'// This defeats the purpose of encapsulation!Use Barrel Exports Wisely
// β
GOOD - Explicit exportsexport { createUser, getUserById } from './user-service'
// β οΈ USE CAREFULLY - Barrel exportexport * from './user-service'// Only if you want to export everything from that fileExtract Common Logic to Shared
If logic is used by multiple modules, move it to shared.
// Before: Duplicated in users and ordersexport function isValidEmail(email: string) { ... }
// src/features/orders/validators.tsexport function isValidEmail(email: string) { ... }
// After: Extracted to shared// src/shared/utils/validation.tsexport function isValidEmail(email: string) { ... }Related Patterns
- Hexagonal Architecture - Can be combined with modular
- Layered Architecture - Alternative organization approach
Next Steps
- Check out the modular example
- Learn about feature slicing strategies
- Read about customizing presets