Skip to content

Frontend Architecture

Angular Gave You Structure, Not Architecture

Angular is opinionated about where files go and silent about what is allowed to depend on what. Teams keep mistaking the first for the second, and end up with codebases that look organized and behave like a tangle.

By Anshul KapoorOctober 20268 min read
Share article
LinkedIn

On this page 7 sections

TL;DR

  • Framework conventions answer where a file goes. Architecture answers what may depend on what. Angular is excellent at the first and says nothing about the second.
  • A tidy folder tree is the most convincing disguise a tangled dependency graph can wear, which is why the problem usually goes unnoticed for years.
  • SharedModule and providedIn root are the two defaults that quietly erase boundaries, one by collecting everything and the other by making everything global.
  • Boundaries that live in a wiki are suggestions. Put them in the linter, where crossing one fails the build.

Perfectly organized, impossible to change

The Angular codebase I think about most was, by every visible measure, well kept. Consistent file naming. Feature folders. Barrel exports. Services in services, components in components, models in models, all the way down. A new engineer could find any file in under ten seconds.

It was also close to unchangeable. Touching the invoicing feature meant rebuilding half the app, because invoicing imported a utility from billing, which imported a type from customers, which imported a formatter from the shared module, which imported something from invoicing. Nobody had designed that loop. It had accumulated one reasonable import at a time, and by the time it was a problem it was load-bearing in eleven places.

What struck me was how long it had gone unnoticed. Every file was where you would expect. The tree looked deliberate. The dependency graph, when we finally drew it, looked like steel wool.

Those two facts are not in tension, and understanding why they are not is the whole point. A folder tree describes where code sits. A dependency graph describes what code knows about. Angular has strong opinions about the first and offers essentially nothing about the second.

“A tidy folder tree is the best disguise a tangled dependency graph can wear.”

What the framework decided for you

Angular gives you more built-in structure than almost any frontend framework. Dependency injection, a module system, a router with its own composition model, a CLI that generates files in the sanctioned shape, a style guide the community broadly follows. Coming from a codebase where every team invented its own conventions, this is a genuine relief.

The relief is also where the confusion starts, because the decisions Angular makes for you and the decisions it leaves open feel similar from the inside.

Angular decides how a component declares its inputs, how a service gets constructed and delivered, how routes nest, how change detection propagates, what a file is called. Real decisions, and having them settled saves arguments.

Angular does not decide whether the invoicing feature is allowed to import from the payments feature. It does not decide whether a component may talk to an HTTP service directly or must go through a facade. It does not decide what belongs to the domain layer versus the view layer, or whether those layers exist. It has no view on which parts of a feature are public and which are internal.

Every one of those is an architectural decision, and every one of them is left entirely to you. The CLI will happily generate a service that imports from anywhere and injects into anything. Nothing in the toolchain will object.

So teams follow the style guide carefully, see the tidy result, and conclude the architecture is handled. The style guide was never about that. It is a formatting agreement wearing the clothes of a design.

The shared module is where boundaries go to die

Every large Angular app I have seen has a SharedModule, and in every one of them it is the largest and most depended-upon thing in the codebase.

It always begins defensibly. Two features need the same date pipe, and duplicating it is silly, so it moves to shared. That is correct. Then a button component moves in. Then a currency formatter, a confirmation dialog, a validation helper, an HTTP interceptor, a small state utility, a constants file. Each move is individually right, and the aggregate is a module with no theme, imported by everything.

Once that exists, two things follow. Every feature now transitively depends on every other feature's utilities, so the dependency graph is complete and the build cannot prune anything. And shared becomes the path of least resistance for any code whose home is unclear, which means the ambiguous cases, exactly the ones that most needed a deliberate decision, all land in the same undifferentiated pile.

The fix is not a rule about what may enter shared, because that rule is unenforceable and everyone can argue their case. The fix is to stop having one destination. Split by purpose: a ui library with presentational components and no domain knowledge, a util library with pure functions and no framework imports, a data-access library per domain. Now placing something requires answering what kind of thing it is, and "I could not decide" stops being an available answer.

“SharedModule starts as a sensible place for the date pipe and ends as the reason every feature depends on every other feature.”

Standalone components removed the ceremony, not the need

Modern Angular has moved away from NgModules toward standalone components, and on balance this is a good change. The module boilerplate was substantial, and the mental overhead of declarations, imports, exports, and providers bought less than it cost.

It has an interesting side effect that is worth naming. NgModules were not real boundaries, but they were a place where a boundary could be written down. A module declared what it contained and exported what it offered, and even if nothing enforced it, the file was a natural spot for a team to express intent about a public surface.

Standalone components remove that spot. A component now imports exactly what it needs, directly, from wherever it lives. This is simpler and more honest. It also means that if you were relying on module boilerplate to make your code look organized, there is now nothing between you and the raw dependency graph.

Teams that had actual boundaries did not notice the transition much. Teams that had ceremony in place of boundaries discovered, somewhat abruptly, that the ceremony had been doing the work.

providedIn root, or how everything became a singleton

providedIn: 'root' is the default for generated services, and it is the right default for most of them. It is also the single most common reason Angular apps stop having a lifecycle story.

A root-provided service is one instance for the life of the application. That is correct for a config service or an auth token holder. It is quietly wrong for anything holding state that belongs to a screen, a workflow, or a selected entity.

The failure looks like this. A wizard has a service holding partial form state. It is root-provided, because everything is. A user completes the wizard, navigates away, comes back, and finds the second run pre-populated with fragments of the first. Someone fixes it by adding a reset() call on init. Then a second entry point to the wizard is added and does not call reset(). Then a bug report arrives about data from another customer's record appearing in a form, which is a much more serious conversation than a stale field.

None of that is a framework flaw. Angular offers component-level providers precisely for this, and scoping the service to the wizard component makes the whole class of bug impossible, since the instance dies with the screen. The problem is that the default is global, the generator does not ask, and "how long should this thing live" is a question the tooling never forces anyone to answer.

TypeScript
// The default. One instance forever, whether or not that is what you meant.
@Injectable({ providedIn: 'root' })
export class InvoiceDraftService { /* holds in-progress state */ }
 
// Scoped to the feature that owns it. Created with the component,
// destroyed with it, and structurally incapable of leaking across sessions.
@Component({
  selector: 'app-invoice-wizard',
  standalone: true,
  providers: [InvoiceDraftService],
  templateUrl: './invoice-wizard.component.html',
})
export class InvoiceWizardComponent {}

The rule I have landed on: stateless services can be root-provided without much thought, and any service holding mutable state should have to justify why its lifetime is the whole application.

Boundaries have to be enforceable

Every team that has this problem has already tried the obvious remedy. Someone writes an architecture document. It describes layers, allowed dependencies, what belongs where. It is thoughtful and correct.

It stops working in about six weeks, and the mechanism is always the same. It is Thursday, a fix is needed, and importing directly from the other feature makes it a two-line change while doing it properly is an afternoon. The engineer knows the rule. They also know the release is tomorrow. They import directly, leave a // TODO and a note in the PR, and the reviewer, who also wants to ship, approves it. The rule was not overruled. It was outvoted by a deadline, once, and then the precedent existed.

Documented boundaries decay because violating them is easier than honoring them and nothing pushes back. The only durable fix is to make the violation fail.

JavaScript
// eslint.config.js
// Features may not reach into other features. Everything crosses through
// a domain's public entry point, or it does not cross.
{
  files: ['src/app/features/**'],
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        {
          group: ['@app/features/*/internal/*', '@app/features/*/lib/*'],
          message:
            'Import from the feature public API (@app/features/<name>) instead of reaching into its internals.',
        },
        {
          group: ['@app/features/*/*'],
          message: 'Cross-feature imports must go through the feature index.',
        },
      ],
    }],
  },
}

The specific tool matters less than the property: the rule now lives where the build can see it. An engineer who needs to cross a boundary gets told at the moment they do it, in their editor, with an explanation. Crossing anyway becomes a deliberate act that shows up in a diff as an eslint-disable comment, which is exactly the kind of thing a reviewer notices and asks about.

The useful test for any architectural rule: what happens when someone breaks it on a Thursday afternoon before a Friday release? If the answer is "a reviewer might catch it," the rule is a suggestion. If the answer is "the build fails and tells them why," it is a boundary.

Structure you can see, boundaries you can enforce

The thing I would tell my earlier self is that a well-organized codebase and a well-architected one are different achievements, and the first is much easier to attain and much easier to mistake for the second.

Organization is visible. You open the repository, the folders are sensible, files are where you would guess, naming is consistent. It looks like care, because it is care. It is also entirely about where code sits at rest.

Architecture is about motion. What can call what. Where the seams are. What you can replace without touching everything else. None of that shows up in a file tree, which is precisely why a tangled system can look immaculate in an editor sidebar and reveal itself only when you try to change something.

Angular hands you the visible part on day one, and that head start is real. It also means the invisible part never gets forced. The framework never asks whether invoicing should know about payments, so a team can go years without anyone asking, and by the time the answer matters it has been answered a thousand times by accident.

So take the structure. It is good, and consistency has real value. Then do the part the framework left for you: decide what your domains are, decide what may depend on what, and put those decisions somewhere a tired engineer will encounter them at the exact moment they are about to violate one.

Angular gave you structure. Architecture is still your job.

“Organization is where code sits. Architecture is what code is allowed to know. Only one of them shows up in the folder tree.”

Found this useful?

Share it with someone who might be wrestling with the same problem.

ShareLinkedIn

Thanks for reading.

If this resonated and you're hiring for Senior/Staff Frontend roles, I'd love to chat.