Software Craft
How to Structure a Software Project
Updated 2026-07-09 · 7 min read
Project structure is one of those things nobody teaches you and everybody has opinions about. The truth is there is no single correct layout — but there are layouts that make a codebase easy to navigate and layouts that make every change feel like defusing a bomb.
The goal is simple: when you or a teammate needs to change something, it should be obvious where that thing lives. Good structure is mostly about making the answer to "where does this go?" boring and predictable.
Start simple, grow deliberately
The most common mistake in a new project is building the folder structure of a system you do not have yet. You create services/, repositories/, adapters/, and interfaces/ before you have written a single feature, and now you are maintaining scaffolding for imaginary complexity.
Start flat. A handful of files in a src/ folder is a perfectly good structure for a small project. Add folders when a folder would genuinely help you find things — usually when a directory grows past a dozen files or when a clear grouping emerges on its own.
Structure should be discovered, not imposed. Let the shape of the code tell you where the seams are, then formalize them. Refactoring a folder layout later is cheap; untangling premature abstraction is not.
Organize by feature, not by type
Here is the decision that matters most. There are two ways to group files:
By type (also called "by layer"):
src/
controllers/
userController.js
orderController.js
models/
user.js
order.js
views/
userView.js
orderView.jsBy feature (also called "by domain"):
src/
users/
userController.js
user.js
userView.js
orders/
orderController.js
order.js
orderView.jsFor anything beyond a toy project, organize by feature. The reason is that changes are almost always feature-shaped, not type-shaped. When you add a field to orders, you touch the order controller, the order model, and the order view. If those files live together, the change is local and easy to reason about. If they are scattered across three type folders, you are jumping around the tree for one logical change.
Grouping by type feels tidy at first — all the models in one place! — but it optimizes for a question nobody asks ("show me every model") over the question everybody asks ("where is the order code?"). Feature folders keep related things together and make it obvious what can be deleted when a feature goes away.
Separate concerns with clear boundaries
Within a feature, still keep different responsibilities apart. The rough division that holds up across most applications:
- Presentation — UI, components, templates. What the user sees.
- Business logic — the rules that make your app yours. What actually happens.
- Data access — talking to the database, files, or external APIs.
The key rule: dependencies should point inward. Your business logic should not import your web framework. It should not know whether it was called from an HTTP handler, a CLI command, or a test. That way you can test the logic without spinning up a server, and swap the database without rewriting the rules.
A practical smell test: if changing your web framework or database means editing your core business rules, the layers are tangled. A well-structured module has its important logic in plain functions that take data and return data, with the framework glue kept thin and at the edges.
Keep configuration and secrets out of the code
Configuration that changes between environments — database URLs, API keys, feature flags — does not belong hardcoded in source. The widely followed rule (from the Twelve-Factor App) is to keep config in the environment.
# .env — never commit this file
DATABASE_URL=postgres://localhost/myapp
STRIPE_KEY=sk_live_...
LOG_LEVEL=infoThen load it once at startup and pass it down. A few rules that save real pain:
- Commit a
.env.examplewith the keys and dummy values so newcomers know what is needed. - Add the real
.envto.gitignore. Committing a secret means rotating it — treat any committed key as compromised. - Validate config at startup and fail loudly if something required is missing. A clear "DATABASE_URL is not set" at boot beats a mysterious crash under load.
Secrets in git history are one of the most common security incidents there is. See Git & Version Control for keeping them out in the first place.
Name things so the layout explains itself
Names are structure too. A good name lets someone predict a file's contents without opening it.
- Be consistent with case. Pick
kebab-case,camelCase, orsnake_casefor files and stick to it across the whole project. - Let names describe purpose, not type.
auth.jstells me more thanutils2.js. - Beware the
utilsorhelpersdumping ground. One shared-helpers file is fine; when it hits a few hundred lines of unrelated functions, it has become a junk drawer. Split it by topic —dates.js,currency.js. - Match the file name to its main export. If a file's job is the
OrderService, name itorderService.js, notservice.js. - Keep a predictable place for tests. Either
foo.test.jsnext tofoo.js, or a mirroredtests/tree — just be consistent.
A layout that scales
Putting it together, here is a structure that works for a medium-sized application and grows gracefully:
myapp/
src/
features/
users/
orders/
billing/
shared/ # genuinely cross-cutting code
db/
config/
logging/
app.js # wires everything together
tests/
scripts/ # one-off and dev tooling
docs/
.env.example
package.json
README.mdThe shared/ folder is where things go when they are truly used across features — but be strict about what earns a place there. Every item in shared/ is a dependency for the whole app, so a bloated shared folder quietly couples everything together. When in doubt, keep code inside the feature that uses it and only promote it to shared on the second or third real consumer.
The README.md at the root is not optional. It should say what the project is, how to run it, and how to run the tests — the three things every newcomer needs in their first ten minutes.
Common mistakes
- Premature structure. Twelve folders and abstractions for a project with three features. Start flat, split when it hurts.
- Organizing by type at scale. Fine for a tutorial, painful for a real app where changes are feature-shaped.
- A bloated
shared/orutils/. Becomes a magnet for unrelated code and couples everything to everything. - Deep nesting.
src/modules/features/user/components/profile/edit/index.jsis a maze. If you are five folders deep, reconsider. - Circular dependencies. Feature A imports B which imports A. Usually a sign the boundary is in the wrong place; extract the shared piece.
- Config in code. Environment-specific values baked into source make deploys fragile and leak secrets.
- No obvious entry point. A newcomer should be able to find where the app starts in seconds.
Checklist
- The layout is as flat as it can be while still grouping clearly.
- Related code is grouped by feature, not scattered by type.
- Business logic does not depend on the framework or database.
- Configuration lives in the environment, with a committed
.env.example. - The real
.envand any secrets are gitignored. - Naming is consistent and files describe their purpose.
- There is one obvious entry point and a README explaining how to run things.
-
shared/contains only genuinely cross-cutting code.
Keep learning
Read the Twelve-Factor App once — it is short and its advice on config, dependencies, and processes underpins most modern deployment thinking. For the deeper "dependencies point inward" idea, look into hexagonal (ports and adapters) architecture; you do not need to adopt it wholesale to benefit from the mindset.
Then go read the source of a well-regarded open-source project in your language and notice how they lay things out — real codebases teach more than any diagram. Pair this with Writing Better Technical Documentation so your structure is explained, and Debugging Software Systematically — a clean structure makes bugs far easier to isolate.
