How We Structure TypeScript Projects for Long-Term Maintainability
TypeScript project structure is one of those topics where everyone has an opinion and few have data. “Feature-based,” “layer-based,” “domain-driven”—the naming conventions proliferate, but the underlying question is simpler than it appears: when a new developer opens this codebase in six months, how quickly can they find what they need and understand how the pieces connect? At Harbor Software, our TypeScript monorepo has grown from 3,000 lines to 47,000 lines over six months. Along the way, we tried three different organizational patterns before settling on one that works for our team size and application complexity. This post documents what we tried, why we changed, and the specific conventions we now follow with code examples.
The Starting Point: Layer-Based Organization
Our first structure mirrored the classic three-tier architecture that every Node.js tutorial teaches:
src/
controllers/
userController.ts
subscriptionController.ts
inferenceController.ts
services/
userService.ts
subscriptionService.ts
inferenceService.ts
repositories/
userRepository.ts
subscriptionRepository.ts
inferenceRepository.ts
models/
user.ts
subscription.ts
inference.ts
middleware/
auth.ts
validation.ts
rateLimit.ts
utils/
hash.ts
date.ts
errors.ts
This is the structure you see in most tutorials and boilerplates. It works at small scale because everything is two clicks away—you know controllers are in controllers/, services are in services/, and so on. But it broke down for us around the 15,000-line mark for two compounding reasons:
- Feature changes require touching every directory. Adding a new field to the subscription model required changes in
models/subscription.ts,repositories/subscriptionRepository.ts,services/subscriptionService.ts,controllers/subscriptionController.ts, and possiblymiddleware/validation.ts. You are navigating five directories instead of one cohesive area. The cognitive overhead is not in finding the files (your editor handles that) but in understanding the data flow across scattered files that have no explicit dependency relationship in the directory structure. - No encapsulation between domains. The user service could import from the subscription repository without going through the subscription service. The inference controller could reach directly into the user model. There was no mechanism—neither technical nor conventional—to prevent cross-domain imports that bypass business logic. As the codebase grew, these shortcuts accumulated into a spaghetti dependency graph that made it impossible to reason about the blast radius of changes. Modifying the subscription repository’s return type required tracing every file that imported it, and some of those imports were in completely unrelated features.
The Pivot: Feature-Based Organization
We reorganized around features (or domains):
src/
features/
users/
user.controller.ts
user.service.ts
user.repository.ts
user.model.ts
user.types.ts
user.routes.ts
__tests__/
user.service.test.ts
user.controller.test.ts
subscriptions/
subscription.controller.ts
subscription.service.ts
subscription.repository.ts
subscription.model.ts
subscription.types.ts
subscription.routes.ts
__tests__/
subscription.service.test.ts
inference/
inference.controller.ts
inference.service.ts
inference.queue.ts
inference.types.ts
__tests__/
inference.service.test.ts
shared/
middleware/
auth.ts
validation.ts
utils/
hash.ts
date.ts
types/
common.ts
errors.ts
config/
database.ts
redis.ts
app.ts
This was a significant improvement. Working on subscriptions means working in one directory. Tests live next to the code they test. New developers can understand the subscription domain by reading the 6–8 files in one folder, without needing to understand the entire codebase. The mental model shifts from “which layer am I in?” to “which feature am I working on?” which maps much more naturally to how we think about product changes.
The migration from layer-based to feature-based took about 3 days for a 15,000-line codebase. Most of the time was spent updating import paths, not restructuring logic. We used TypeScript’s path aliases (@/features/users) instead of relative paths, which made the import updates mechanical rather than error-prone.
But we still had one problem: there was no enforced boundary between features. The inference service could still import user.repository directly, bypassing the user service’s business logic and its validation, authorization, and audit logging. We needed a contract layer—a way to declare “this is the public API of the users feature” and prevent other features from reaching into its internals.
The Current Structure: Features With Explicit Interfaces
Our current structure adds an index.ts barrel export to each feature that defines its public API:
src/
features/
users/
index.ts # PUBLIC API: exports only what other features can use
user.controller.ts # Internal: HTTP layer
user.service.ts # Internal: business logic
user.repository.ts # Internal: data access
user.model.ts # Internal: database model
user.types.ts # Internal: domain types
user.routes.ts # Internal: route definitions
user.errors.ts # Internal: domain-specific errors
__tests__/
subscriptions/
index.ts
# ... same pattern
inference/
index.ts
# ... same pattern
The index.ts barrel file is the enforcement mechanism. It explicitly declares what is public and implicitly declares everything else as internal:
// features/users/index.ts
// This is the ONLY file other features should import from.
// Everything else is an internal implementation detail.
export { UserService } from './user.service';
export type { User, CreateUserInput, UserWithSubscription } from './user.types';
export { UserNotFoundError, DuplicateEmailError } from './user.errors';
export { userRoutes } from './user.routes';
// NOT exported: UserRepository, user.model, internal helpers
// These are implementation details that other features should not depend on.
We enforce this boundary with an ESLint rule that prevents deep imports:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@/features/*/!(index)'],
message: 'Import from the feature's index.ts, not internal modules. Use @/features/users instead of @/features/users/user.repository.'
}
]
}]
}
};
This ESLint rule generates an error if any file outside the users/ directory tries to import @/features/users/user.repository directly. They must import from @/features/users (which resolves to index.ts). This rule runs in CI, so deep imports cannot be merged to main. Since adding this rule four months ago, we have had zero cross-feature dependency issues—compared to roughly one per week before.
Naming Conventions
Consistent naming eliminates an entire category of decision-making. When every file and every type follows a predictable pattern, developers do not waste time wondering “what should I call this?” or searching for a file that might be named any of three things. Here are our conventions:
- Files:
feature.layer.ts— e.g.,subscription.service.ts,subscription.controller.ts,subscription.repository.ts. The feature name comes first so files sort by feature in directory listings. - Types: Pascal case, suffixed by purpose —
CreateUserInput,UserWithSubscription,InferenceResult,PaginatedResponse<T>. The suffix tells you how the type is used without reading the definition. - Interfaces vs. Types: We use
typefor data shapes (things that describe the structure of data) andinterfacefor contracts (things that will be implemented by classes). This is a convention, not a technical requirement—TypeScript treats them almost identically—but the distinction improves readability. - Error classes:
FeatureActionError— e.g.,UserNotFoundError,SubscriptionExpiredError,InferenceLimitExceededError. Every domain error extends a baseAppErrorclass that includes an HTTP status code and error code. - Test files:
feature.layer.test.ts— co-located in a__tests__directory within the feature. Co-location matters: when you see a feature directory, you immediately know whether it has tests.
The Shared Directory
The shared/ directory contains code that is genuinely used across multiple features. We have a strict rule: code starts in a feature directory and only moves to shared/ when it is actually used by two or more features. The temptation to preemptively extract “reusable” utilities is strong and should be resisted—premature abstraction creates maintenance burden (one more place to look, one more abstraction to understand) without providing value until the second consumer exists.
src/
shared/
middleware/
auth.middleware.ts # JWT verification, used by all routes
rate-limit.middleware.ts # Rate limiting, used by all routes
error-handler.middleware.ts # Global error handler
utils/
hash.ts # Password hashing (bcrypt wrapper)
date.ts # Date formatting helpers
logger.ts # Structured logger (pino wrapper)
result.ts # Result<T, E> type for error handling
types/
common.ts # Pagination, sorting, ID types
errors.ts # Base error classes
database/
client.ts # Database connection singleton
migrations/ # Migration files
Every file in shared/ has at least two consumers. We verify this during code review—if a PR adds something to shared/ and only one feature uses it, it belongs in that feature directory instead.
TypeScript Configuration for Correctness
Our tsconfig.json has evolved through painful lessons. Two settings that matter most for long-term maintainability:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
noUncheckedIndexedAccess is the single most impactful strictness flag for catching runtime bugs at compile time. With it enabled, array[0] has type T | undefined instead of T. This forces you to handle the case where the array is empty, which eliminates an entire class of runtime “Cannot read property of undefined” errors. In the first week after enabling this flag, it caught 14 potential runtime errors in our codebase that had been lurking undetected. It is annoying for the first week (you have to add null checks everywhere) and pays for itself permanently after that.
exactOptionalPropertyTypes distinguishes between “property is missing” ({}) and “property is explicitly undefined” ({name: undefined}). In a world of API responses and database results where the difference between “field not included” and “field set to null/undefined” matters for business logic, this distinction prevents subtle bugs. For example, a PATCH request where name is missing means “don’t change the name,” while name: undefined might mean “clear the name.” Without this flag, TypeScript treats both the same way.
Error Handling Pattern: Result Types
We use a Result type instead of throwing exceptions for expected business logic errors. This makes error handling explicit in function signatures and impossible to forget:
// shared/utils/result.ts
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function ok<T>(data: T): Result<T, never> {
return { success: true, data };
}
function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
export { Result, ok, err };
Usage in the service layer:
class UserService {
async createUser(
input: CreateUserInput
): Promise<Result<User, DuplicateEmailError | InvalidInputError>> {
// Validate
if (!isValidEmail(input.email)) {
return err(new InvalidInputError('Invalid email format'));
}
// Check uniqueness
const existing = await this.repo.findByEmail(input.email);
if (existing) {
return err(new DuplicateEmailError(input.email));
}
// Create
const user = await this.repo.create(input);
return ok(user);
}
}
// Usage in controller
class UserController {
async create(req: Request, res: Response) {
const result = await this.userService.createUser(req.body);
if (!result.success) {
if (result.error instanceof DuplicateEmailError) {
return res.status(409).json({ error: result.error.message });
}
if (result.error instanceof InvalidInputError) {
return res.status(400).json({ error: result.error.message });
}
return res.status(500).json({ error: 'Unknown error' });
}
return res.status(201).json(result.data);
}
}
The function signature Promise<Result<User, DuplicateEmailError | InvalidInputError>> tells you exactly what can go wrong without reading the implementation. TypeScript’s exhaustive checking in the controller ensures every error type is handled—if you add a new error type to the service, the controller gets a type error until you handle it. Compare this with thrown exceptions, which are invisible in type signatures, easy to forget to catch, and require reading source code or documentation to discover what a function might throw.
We reserve thrown exceptions for truly exceptional situations: out of memory, network partition, database connection lost, assertion violations. These are not business logic errors—they are infrastructure failures that should crash the request and be caught by the global error handler.
What We Would Change
Three things we got wrong and would fix if starting over:
- Set up path aliases from day one. We added the
@/alias at the 20,000-line mark, which required updating hundreds of relative imports (../../shared/utils/hashto@/shared/utils/hash). This was a tedious, error-prone migration that took two days and would have been free if done at project creation. Path aliases also make it possible to enforce import boundaries with ESLint rules, which is how we prevent deep feature imports. - Adopt the Result pattern from the start. We started with thrown exceptions and migrated to the Result type at the 30,000-line mark. The migration was straightforward but time-consuming: change every service method’s signature, update every caller to handle the Result type, add exhaustive error handling in every controller. This took a week. The Result pattern is unambiguously better for domain errors—start with it.
- Use a monorepo tool earlier. At 47,000 lines, we are approaching the point where build times and test times are noticeable. Tools like Turborepo or Nx provide incremental builds (only rebuild what changed), task caching, and parallel execution. We should have adopted one when the codebase was smaller and the migration was trivial.
Conclusion
Project structure is not about following a specific pattern from a blog post. It is about reducing the time between “I need to change X” and “I found the file, understood the context, and know what else might be affected.” Feature-based organization with explicit public interfaces achieves this by keeping related code together and making cross-feature dependencies visible and enforceable. Add strict TypeScript configuration, consistent naming, co-located tests, and an ESLint rule that prevents deep imports, and you have a codebase that remains navigable and maintainable as it grows from 3,000 to 50,000 lines and beyond.
Migration Guide: Layer-Based to Feature-Based
If your codebase currently uses layer-based organization and you want to migrate, here is the process we followed. It took 3 days for 15,000 lines of code and can be done incrementally—you do not need to migrate everything at once.
Step 1: Set up path aliases. Add @/* path alias in tsconfig.json before starting the migration. This lets you use absolute imports (@/features/users) instead of relative paths, making the import updates during migration simpler and preventing the “how many ../ do I need?” problem.
Step 2: Migrate one feature at a time. Pick the smallest, most self-contained feature (for us, it was the user module). Create the features/users/ directory, move all user-related files into it, update their imports, and add an index.ts barrel. Update all external references to import from the barrel. Run tests to verify nothing broke. Commit. Move to the next feature.
Step 3: Add the ESLint rule. Only add the deep-import prevention rule after all features have been migrated. Adding it during migration creates a flood of ESLint errors in files you have not migrated yet, which is demoralizing and makes CI unusable. Once all features are migrated and all imports go through barrel files, enable the rule and let CI enforce it going forward.
Step 4: Clean up shared/. After migration, review what ended up in shared/. Some utilities that seemed shared during layer-based organization turn out to be used by only one feature. Move those back into the feature directory. The goal is a shared/ directory where every file has two or more consumers, verified by a quick grep.
The key insight from our migration: do it in small, tested increments. Each increment moves one feature, updates its imports, and verifies with tests. If anything breaks, the blast radius is one feature, not the entire codebase. Resist the temptation to do a “big bang” reorganization over a weekend—we have seen that fail at previous companies, and the result is a week of broken builds and frustrated developers.
One final note: the migration is a good opportunity to delete dead code. During our migration, we discovered that 8% of our codebase (roughly 1,200 lines) was completely unused—functions that were never called, types that were never instantiated, middleware that was registered but handled a deprecated endpoint. Moving files one feature at a time forces you to examine each file and its imports, which naturally surfaces code that nothing depends on. We deleted more code during the migration than we wrote, and the codebase was healthier for it. A migration that leaves you with fewer lines of code than you started with is a successful migration.