Boundaries define the limits where the HMR system can safely replace modules. Understanding Boundaries properly when developing with Sonamu allows you to maximize HMR benefits.
What is a Boundary?
A Boundary means a βreloadable limitβ. The HMR system operates by the following rules:
- Boundary files: Files that can be reloaded on change (Model, API, Entity, etc.)
- Non-boundary files: Files that require a full server restart on change (config, environment variables, etc.)
β
Boundary files: Changes are instantly reflected via HMR
β Non-boundary files: Require server restart on change
Why are Boundaries Necessary?
Static imports execute only once when Node.js starts, so they cannot be replaced at runtime:
// β Static import - HMR not possible
import { UserModel } from "./user.model";
// β
Dynamic import - HMR possible
const { UserModel } = await import("./user.model");
Files designated as Boundaries must be imported dynamically, so that the latest code can be fetched on change.
Sonamuβs Boundary Configuration
By default, Sonamu sets all TypeScript files in the project as Boundaries:
// hmr-hook-register.ts
await hot.init({
rootDirectory: process.env.API_ROOT_PATH,
boundaries: [`./src/**/*.ts`], // All .ts files are boundaries
});
With this configuration, all files under src/ are HMR-capable and automatically reload on change.
Boundary File Examples
Boundary files (HMR capable):
user.model.ts - Model file
user.api.ts - API file
user.entity.ts - Entity definition
helpers.ts - Utility functions
constants.ts - Constants definition
Non-boundary files (restart required):
sonamu.config.ts - Sonamu configuration
.env - Environment variables
package.json - Dependencies
Boundary Rules
1. Dynamic Import Required
Boundary files must be imported dynamically.
β Wrong: Static import
// server.ts
import { UserModel } from "./application/user/user.model";
// Changes to UserModel won't be reflected!
app.get("/users", async (req, res) => {
const users = await UserModel.findMany();
res.json(users);
});
β
Correct: Dynamic import
// server.ts
app.get("/users", async (req, res) => {
const { UserModel } = await import("./application/user/user.model");
// When UserModel is modified, latest code is loaded on next request!
const users = await UserModel.findMany();
res.json(users);
});
Sonamu Handles This Automatically
Fortunately, Sonamuβs Syncer automatically handles dynamic imports for Entity-based files:
// syncer.ts - autoloadModels()
async autoloadModels() {
for (const entity of entities) {
const modelPath = `${entity.path}/${entity.id}.model`;
// Loaded via dynamic import β
const module = await import(modelPath);
this.models[entity.id] = module[`${entity.id}Model`];
}
}
In other words, you donβt need to worry about this in typical Sonamu development!
2. Static Imports Between Boundaries Allowed
Static imports between Boundary files are allowed (Sonamuβs enhancement):
// user.model.ts (boundary)
import { PostModel } from "./post.model"; // β
OK - both are boundaries
export class UserModel extends BaseModel {
async getPosts() {
return PostModel.findMany({ where: { userId: this.id } });
}
}
This works because:
- Both files are Boundaries
- Both are dynamically loaded by Syncer
- HMR works correctly even when they reference each other
3. Non-boundary to Boundary Import Restriction
HMR wonβt work if a non-boundary file statically imports a Boundary:
// server.ts (non-boundary)
import { UserModel } from "./user.model"; // β Full restart required
// server.ts (non-boundary) - correct approach
async function loadModels() {
const { UserModel } = await import("./user.model"); // β
OK
}
Practical Scenarios
Using HMR in User-Post Relationship
Situation: Adding a role field to User Entity and adding author permission check in Post API.
Step 1: Modify Entity
// Add role to User in Sonamu UI
role: EnumProp<"admin" | "user">
On save, HMR automatically:
- Updates
user.entity.ts
- Regenerates
user.types.ts
- Reloads
UserModel
Console output:
π Invalidated:
- src/application/user/user.entity.ts
- src/application/user/user.model.ts
Step 2: Add Model Logic
// user.model.ts
export class UserModel extends BaseModel {
isAdmin(): boolean {
return this.role === "admin";
}
canCreatePost(): boolean {
return this.isAdmin() || this.role === "user";
}
}
On save, HMR:
- Reloads
UserModel
- Reloads all APIs that use
UserModel
Step 3: Add Permission Check to API
// post.api.ts
import { UserModel } from "../user/user.model"; // β
Both are boundaries
@api({ httpMethod: "POST" })
async create(body: PostForm) {
const user = await UserModel.findById(body.userId);
if (!user.canCreatePost()) { // π Use the method just added!
throw new UnauthorizedError("You don't have permission to create posts");
}
return PostModel.save(body);
}
On save, HMR:
- Re-registers
PostApi.create
- Testable immediately without server restart!
π Invalidated:
- src/application/post/post.api.ts
β¨ API re-registered: POST /api/post/create
Traditional approach:
- Modify Entity
- Manually generate Types file
- Modify Model file
- Server restart (30 seconds)
- Modify API file
- Server restart (30 seconds)
- Test
Sonamu + HMR:
- Modify Entity (auto-generated)
- Modify Model file (auto-reload)
- Modify API file (auto-reload)
- Test β
3 restarts (90 seconds) reduced to 0!
Complex Business Logic Development
Situation: Implementing inventory check, payment processing, and notification on order creation.
File structure:
Step 1: Inventory Check Logic
// product.model.ts
export class ProductModel extends BaseModel {
hasStock(quantity: number): boolean {
return this.stock >= quantity;
}
async reserveStock(quantity: number) {
if (!this.hasStock(quantity)) {
throw new BadRequestError("Out of stock");
}
await this.update({ stock: this.stock - quantity });
}
}
Save β 2 seconds later β Instantly reflected β
Step 2: Order Creation Logic
// order.model.ts
import { ProductModel } from "../product/product.model"; // β
OK
import { PaymentService } from "../payment/payment.service";
export class OrderModel extends BaseModel {
static async createOrder(productId: number, quantity: number, userId: number) {
const product = await ProductModel.findById(productId);
// Can use ProductModel.reserveStock() immediately!
await product.reserveStock(quantity);
const order = await this.save({
productId,
quantity,
userId,
totalPrice: product.price * quantity,
status: "pending",
});
return order;
}
}
Save β ProductModel dependency also auto-reloaded β
Step 3: API Endpoint
// order.api.ts
import { OrderModel } from "./order.model";
@api({ httpMethod: "POST" })
async create(body: { productId: number; quantity: number }) {
const userId = getSonamuContext().userId!;
// Can use OrderModel.createOrder() immediately!
const order = await OrderModel.createOrder(
body.productId,
body.quantity,
userId
);
return order;
}
Save β API re-registered β Test immediately in Postman!
HMR log:
π Invalidated:
- src/application/product/product.model.ts
- src/application/order/order.model.ts
- src/application/order/order.api.ts (with 3 APIs)
β¨ API re-registered: POST /api/order/create
β
All files are synced!
In Boundary files, you can use the import.meta.hot API to finely control HMR behavior.
dispose() - Resource Cleanup
Perform cleanup before module reload:
// notification.service.ts
export class NotificationService {
private static timer: NodeJS.Timeout;
static startPolling() {
this.timer = setInterval(() => {
console.log("Checking notifications...");
}, 5000);
}
}
// Cleanup timer before reload
import.meta.hot?.dispose(() => {
clearInterval(NotificationService.timer);
console.log("Cleaned up notification polling timer");
});
Cases requiring resource cleanup:
- Clear timers/intervals
- Remove event listeners
- Close WebSocket connections
- Clean up database connection pools
- Close file handles
Practical usage example:
// websocket-manager.ts
class WebSocketManager {
private static connections = new Map<string, WebSocket>();
static connect(url: string) {
const ws = new WebSocket(url);
this.connections.set(url, ws);
return ws;
}
}
import.meta.hot?.dispose(() => {
// Close all WebSocket connections
for (const [url, ws] of WebSocketManager.connections) {
ws.close();
console.log(`Closed WebSocket: ${url}`);
}
WebSocketManager.connections.clear();
});
decline() - Require Full Restart
Exclude specific modules from HMR and require full restart:
// config.ts
export const config = {
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
},
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
},
};
// Full restart when this file changes
import.meta.hot?.decline();
When to use decline():
- Global configuration files
- Database connection settings
- Singletons with complex initialization
- Modules with difficult state management
Practical usage example:
// database.config.ts
import { Sonamu } from "@sonamu-kit/sonamu";
export const dbPool = new Pool({
host: Sonamu.config.database.host,
port: Sonamu.config.database.port,
user: Sonamu.config.database.user,
password: Sonamu.config.database.password,
database: Sonamu.config.database.database,
});
// DB connection config changes require restart
import.meta.hot?.decline();
boundary Object - State Sharing
Share data between Boundaries to maintain state after reload:
// cache-manager.ts
const cache = import.meta.hot?.boundary.userCache || new Map();
export class CacheManager {
static set(key: string, value: any) {
cache.set(key, value);
}
static get(key: string) {
return cache.get(key);
}
}
// Maintain cache even after reload
import.meta.hot?.boundary.userCache = cache;
Practical usage example:
// rate-limiter.ts
const requestCounts = import.meta.hot?.boundary.requestCounts || new Map<string, number>();
export class RateLimiter {
static check(userId: string): boolean {
const count = requestCounts.get(userId) || 0;
if (count >= 100) {
return false; // Rate limit exceeded
}
requestCounts.set(userId, count + 1);
return true;
}
}
// Maintain request counts after HMR
import.meta.hot?.boundary.requestCounts = requestCounts;
The boundary object maintains data between HMR cycles, but is reset on server restart. Use database or Redis for data requiring persistent storage.
Type Definitions
To use import.meta.hot in TypeScript, type definitions are needed:
// src/types/import-meta.d.ts
interface ImportMeta {
readonly hot?: {
dispose(callback: () => Promise<void> | void): void;
decline(): void;
boundary: Record<string, any>;
};
}
Sonamu projects already include type definitions, so no separate configuration is needed.
Debugging
Check Boundary Configuration
Verify if a specific file is configured as a Boundary:
const dump = await hot.dump();
const userModel = dump.find(d => d.nodePath.includes("user.model.ts"));
console.log(`Boundary: ${userModel?.boundary}`); // true
console.log(`Reloadable: ${userModel?.reloadable}`); // true
console.log(`Children:`, userModel?.children); // Dependent files
Output example:
{
"nodePath": "/project/src/application/user/user.model.ts",
"boundary": true,
"reloadable": true,
"children": [
"/project/src/application/user/user.api.ts",
"/project/src/application/post/post.api.ts",
"/project/src/application/admin/admin.api.ts"
]
}
Visualize Dependency Tree
const dump = await hot.dump();
for (const node of dump) {
if (node.boundary) {
console.log(`π¦ ${node.nodePath}`);
for (const child of node.children || []) {
console.log(` ββ ${child}`);
}
}
}
Output:
π¦ /project/src/application/user/user.model.ts
ββ /project/src/application/user/user.api.ts
ββ /project/src/application/post/post.api.ts
π¦ /project/src/application/post/post.model.ts
ββ /project/src/application/post/post.api.ts
If HMR isnβt working during development:
- Verify the file is designated as a Boundary
- Check if itβs being imported dynamically
- Check for circular dependencies
Exclude Unnecessary Boundaries
Setting all files as Boundaries can slow things down due to a large dependency tree:
// hmr-hook-register.ts
await hot.init({
rootDirectory: process.env.API_ROOT_PATH,
boundaries: [
// Only frequently changed files as Boundaries
"./src/**/*.model.ts",
"./src/**/*.api.ts",
"./src/**/*.service.ts",
],
});
Minimize Dependencies
Avoid circular references between Models and only import whatβs needed:
// β Bad example
import { UserModel } from "../user/user.model";
import { PostModel } from "../post/post.model";
import { CommentModel } from "../comment/comment.model";
// ... many imports
// β
Good example
import { UserModel } from "../user/user.model"; // Only what's actually used
Summary
Sonamuβs Boundary system:
β
Automatic handling: Syncer automatically handles dynamic imports for Entity-based files
β
Cross-boundary references allowed: Static imports between Models permitted
β
Fine-grained control: Resource cleanup and state maintenance via import.meta.hot API
β
Debugging tools: Check dependency tree with hot.dump()
In most cases, you donβt need to worry about Boundaries - Sonamu handles it automatically!