Engineering
SOLID Principles Applied in Angular
Single responsibility through dependency inversion — concrete Angular examples of designs that stay changeable as features grow.
SOLID is not ceremony — it is a checklist for keeping modules replaceable when product requirements shift. Angular's dependency injection, standalone components, and interface-based contracts make several of these principles natural once you know where to look. The cost of ignoring them shows up quickly in CMS and admin UIs: a content editor change ripples through list views, permission checks, and audit trails because everything lives in one oversized component.
This post distills the lessons from our interactive tutorial, available as an open-source reference at learn_solid-principles-in-angular and live at learn-solid.org. Each principle includes side-by-side bad and good implementations with syntax highlighting you can step through in the browser.
Why SOLID Matters in Angular Admin UIs
Admin surfaces accumulate edge cases: draft versus published states, role-based field visibility, bulk actions, and integrations with external DAM or CRM systems. Without deliberate boundaries, a single feature team ends up owning every concern in one file. SOLID gives you a shared vocabulary for splitting those concerns — and Angular's DI container gives you the wiring to do it without global singletons.
Core insight
In Angular, SOLID usually means thin components, focused injectables, and abstract tokens at integration seams. The framework already pushes you in this direction; the principles tell you when to stop adding responsibilities to a class.
The five principles at a glance
- S — Single Responsibility — one reason to change per class
- O — Open/Closed — extend behavior without editing existing code
- L — Liskov Substitution — subtypes honor the contract of their base
- I — Interface Segregation — small, purpose-built interfaces
- D — Dependency Inversion — depend on abstractions, not concretions
S — Single Responsibility Principle
A class should have only one reason to change. In Angular, the most common violation is a component that fetches data, maps DTOs, applies business rules, and renders the template. When the API shape changes, you touch the same file as when the layout changes.
Bad — fetch and display together
@Component({ selector: 'app-user-list', standalone: true })
export class UserListComponent implements OnInit {
users: User[] = [];
constructor(private http: HttpClient) {}
ngOnInit() {
this.http.get<User[]>('/api/users').subscribe(
(data) => (this.users = data)
);
}
}Good — component + UserService
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
}
@Component({ selector: 'app-user-list', standalone: true })
export class UserListComponent {
users$ = inject(UserService).getUsers();
}For CMS teams, apply the same split to content workflows: a ContentListComponent renders rows; a ContentQueryService owns pagination and filters; a ContentMapper converts API payloads to view models. Each piece changes for a different reason.
O — Open/Closed Principle
Software entities should be open for extension but closed for modification. Admin UIs often start with a handful of discount rules or export formats, then grow. Adding another if branch inside a component method works once; it does not scale across tenants or feature flags.
// Extend via strategy + InjectionToken — no edits to existing callers
export interface ExportStrategy {
export(records: ContentRecord[]): Observable<Blob>;
}
export const EXPORT_STRATEGY = new InjectionToken<ExportStrategy>('ExportStrategy');
@Injectable()
export class CsvExportStrategy implements ExportStrategy {
export(records: ContentRecord[]) { /* ... */ }
}
// Register per route or tenant in providers:
// { provide: EXPORT_STRATEGY, useClass: CsvExportStrategy }Angular's multi-provider pattern and useClass / useFactory switches let you add a PDF or JSON exporter by registering a new strategy — not by opening the bulk-action component and adding another branch.
L — Liskov Substitution Principle
Subtypes must be substitutable for their base types without breaking callers. This surfaces in Angular when a base service or component is extended for a “special” case that throws on unsupported operations or changes expected return shapes.
// Violation: ReadOnlyContentStore throws on save()
class ReadOnlyContentStore extends ContentStore {
override save() {
throw new Error('Not allowed');
}
}
// Fix: shared interface, separate implementations
interface ContentReader { load(id: string): Observable<Content>; }
interface ContentWriter { save(content: Content): Observable<void>; }
// Editor injects both; preview injects ContentReader onlyIf a preview panel cannot call save, it should not receive a type that advertises save and then fail at runtime. Split the contract instead.
I — Interface Segregation Principle
Clients should not depend on interfaces they do not use. CMS admin panels tempt you toward god-interfaces — one ContentService with publish, unpublish, audit, export, translate, and webhook methods. Components that only list drafts still import the full surface area and become harder to mock in tests.
Bad — monolithic interface
interface ContentOperations {
publish(id: string): Observable<void>;
unpublish(id: string): Observable<void>;
runAudit(id: string): Observable<AuditReport>;
exportBulk(ids: string[]): Observable<Blob>;
syncWebhooks(id: string): Observable<void>;
}Good — segregated contracts
interface Publishable {
publish(id: string): Observable<void>;
unpublish(id: string): Observable<void>;
}
interface Auditable {
runAudit(id: string): Observable<AuditReport>;
}
interface BulkExportable {
exportBulk(ids: string[]): Observable<Blob>;
}A publish button component depends on Publishable. An audit drawer depends on Auditable. The concrete ContentApiService can implement all three without forcing every consumer to know about every capability.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules; both should depend on abstractions. This is where Angular DI earns its keep. Components and feature services should inject tokens and interfaces, not raw HttpClient calls scattered through the tree.
export abstract class ContentRepository {
abstract findById(id: string): Observable<Content>;
abstract save(content: Content): Observable<void>;
}
@Injectable()
export class HttpContentRepository extends ContentRepository {
constructor(private http: HttpClient) { super(); }
findById(id: string) {
return this.http.get<Content>(`/api/content/${id}`);
}
save(content: Content) {
return this.http.put<void>(`/api/content/${content.id}`, content);
}
}
// Feature module:
// { provide: ContentRepository, useClass: HttpContentRepository }
// Tests: { provide: ContentRepository, useClass: InMemoryContentRepository }Dependency inversion is the through-line of the other four principles: SRP creates focused classes, OCP and LSP define stable extension points, ISP keeps abstractions narrow, and DIP wires them through the injector so you can swap implementations per environment or test.
Angular-specific enablers
Use InjectionToken for non-class dependencies, providedIn or route-level providers for scope, and standalone components to keep feature boundaries explicit. The tutorial app uses Angular Material with a dark theme and Prism highlighting so you can compare implementations without leaving the browser.
Practical Takeaways for CMS and Admin Teams
- Split components early. If a file handles HTTP, validation, and template logic, extract a service before the next feature lands on the same class.
- Extend with providers, not conditionals. New export formats, permission backends, and workflow steps should register new implementations — not inflate shared methods.
- Design interfaces from consumer needs. Ask what each component actually calls, then define the smallest interface that satisfies it.
- Test through abstractions. Provide in-memory repositories and fake strategies in
TestBedso unit tests do not require HTTP mocking everywhere. - Review for substitution safety. If a subclass overrides a method to throw, the base type is probably too wide — segregate or compose instead.
Signs SOLID is working
- Feature changes touch one service or one component, not both
- New tenant rules add a provider, not a twenty-line switch
- Mocks in tests implement the same narrow interface as production
Signs to refactor
- Components import HttpClient or environment config directly
- Subclasses override methods with “not supported” errors
- Every new workflow edit opens the same 800-line component
Closing Checklist
SOLID is a design pressure test, not a badge. Apply it where change actually happens: content lifecycles, permission models, integration adapters, and bulk operations. Use the live demo to walk through each principle with runnable code, then adopt the patterns that match your team's rate of change. The goal is an admin UI where the next engineer can add a workflow step or swap an API backend without unraveling the entire feature module.