← Writing
The Editorial · Engineering

The Module pattern — or why a kitchen has a pass-through window, not a walk-in door

Issue 016Jul 29, 20265 min read

Nobody eats in the kitchen. Whatever's cooking, whatever's half-chopped on the counter, whatever the recipe says — none of that leaves the room. Only finished plates cross the pass-through window, and that's the only part the dining room ever touches. A JavaScript module works the same way: the file is the kitchen, its top-level state is everything on the counter, and export is the only window cut into the wall. Here it is, in one small, real example.

The issue

Nobody eats in the kitchen. Whatever's half-chopped on the counter, whatever's still in the pan, whatever the recipe says — none of it leaves the room. The only thing that crosses the pass-through window is a finished plate, and that's the only part of the kitchen the dining room ever gets to touch. A JavaScript module works the same way: the file is the kitchen, its top-level variables are the counter, and export is the one window cut into the wall.

the shape of a module boundaryauth-state.js — its own scopetoken, expiresAt — privatelogin(), getToken(), logout()login.jsheader.jsonly what's exported crosses the boundary — token and expiresAt never do
Fig 1The shape of a module: whatever isn't exported stays inside the file — only the functions in the window are ever reachable from outside.AI-generated figure

Say your app needs an auth token in a handful of unrelated places: the header decides whether to show an avatar or a login button, the API client attaches it to every request, and a route guard redirects to /login when it's missing or expired. All three need to read it. None of them should be able to just reach in and change it.

The naive version

The obvious move — export the state object itself, and let everyone touch it directly:

auth-state.jsJavaScript
export const authState = {
  token: null,
  expiresAt: null,
};

export function login(token, expiresIn) {
  authState.token = token;
  authState.expiresAt = Date.now() + expiresIn;
}
export const authState = { ... }token: "abc123"expiresAt: 1699999999debug-panel.jsauthState.token = "debug"checkout.jsauthState.expiresAt = 0const locks the binding, not the properties on the object it points to
Fig 2const only locks the authState binding, not what's on it — any importer can still reach in and set token or expiresAt directly.AI-generated figure

Fix: keep the state, export the API

Move token and expiresAt out of anything exported, and let functions be the only way in or out. Nothing outside this file can name them anymore — it can only call what's exported:

auth-state.jsJavaScript
let token = null;
let expiresAt = null;

export function login(newToken, expiresIn) {
  token = newToken;
  expiresAt = Date.now() + expiresIn;
}

export function logout() {
  token = null;
  expiresAt = null;
}

export function getToken() {
  return isAuthenticated() ? token : null;
}

export function isAuthenticated() {
  return token !== null && Date.now() < expiresAt;
}
token/expiresAt private — only functions cross outtoken, expiresAt — privatelogin() logout() getToken() isAuthenticated()header.jsapi-client.jsroute-guard.jsthree callers, one shared token — none of them can name it directly
Fig 3token and expiresAt no longer have names outside this file — every read or write has to go through one of the four exported functions.AI-generated figure

Every other file that cares about auth now goes through the same four functions, whether it's reading or writing:

header.js, api-client.js, route-guard.jsJavaScript
import { getToken, isAuthenticated, logout } from "./auth-state.js";

if (!isAuthenticated()) redirectToLogin();

fetch("/api/orders", {
  headers: { Authorization: `Bearer ${getToken()}` },
});

The other half: one file, evaluated once

auth-state.js only ever runs its top-level code once per page load — every file that imports it after that gets the same already-initialized token and expiresAt, not a fresh copy. That's singleton-style sharing without a class or a new anywhere in sight; Issue 012 builds the same guarantee by hand with a class — a module gets it for free, just by being a module.

For more on the plumbing underneath this — live bindings, circular imports, dynamic import() for code-splitting — patterns.dev's Module Pattern writeup is a good next stop.

All issues