Angular components often start simple.
You create a component, inject a service, fetch some data, and display it. Everything feels straightforward.
Then the feature grows.
You add filters, pagination, validation, loading states, error handling, business rules, and a few more operations. Before you realize it, your component is responsible for almost everything.
At this point, a familiar recommendation appears:
"Your component is doing too much. Move the logic into a service."
But is that always the right solution?
In this article, I want to explore where Angular component logic should live, when extracting it actually helps, and why moving everything into services can create a different kind of architectural problem.
The goal isn't to make every component as small as possible.
The goal is to put each responsibility where it makes the most sense.
1. When Components Start Doing Too Much
Let's consider a simple appointment management feature.
Initially, our component only retrieves appointments and displays them.
@Component({ selector: 'app-appointments', templateUrl: './appointments.component.html', }) export class AppointmentsComponent { appointments: Appointment[] = []; constructor(private http: HttpClient) {} ngOnInit(): void { this.http .get<Appointment[]>('/api/appointments') .subscribe((appointments) => { this.appointments = appointments; }); } } For a small feature, this might be perfectly acceptable.
However, requirements start to grow:
- Users can filter appointments.
- Appointments can be cancelled.
- Cancellation requires validation.
- Loading and error states need to be handled.
- Appointment status determines which actions are available.
- Business rules must be enforced consistently.
Eventually, the component might look something like this:
@Component({ selector: 'app-appointments', templateUrl: './appointments.component.html', }) export class AppointmentsComponent implements OnInit { appointments: Appointment[] = []; filteredAppointments: Appointment[] = []; loading = false; errorMessage = ''; searchTerm = ''; constructor(private http: HttpClient) {} ngOnInit(): void { this.loadAppointments(); } loadAppointments(): void { this.loading = true; this.http.get<Appointment[]>('/api/appointments').subscribe({ next: (appointments) => { this.appointments = appointments; this.applyFilters(); this.loading = false; }, error: () => { this.errorMessage = 'Unable to load appointments.'; this.loading = false; }, }); } applyFilters(): void { this.filteredAppointments = this.appointments.filter( (appointment) => appointment.vehicleId .toLowerCase() .includes(this.searchTerm.toLowerCase()), ); } cancelAppointment(appointment: Appointment): void { if (appointment.status === 'cancelled') { return; } if (appointment.date < new Date()) { return; } this.http .post(`/api/appointments/${appointment.id}/cancel`, {}) .subscribe(() => { this.loadAppointments(); }); } canCancel(appointment: Appointment): boolean { return ( appointment.status !== 'cancelled' && appointment.date > new Date() ); } } The problem isn't necessarily the number of lines.
The component now has several responsibilities:
- Communicating with the API.
- Managing UI state.
- Filtering data.
- Enforcing cancellation rules.
- Coordinating operations.
- Determining business-related permissions.
Some of these responsibilities naturally belong in a component.
Others probably don't.
2. Not All Logic Is Business Logic
Before refactoring, I think it's important to distinguish between different types of logic.
Not everything that looks like logic needs to leave the component.
Presentation logic
This includes decisions directly related to displaying information.
getStatusLabel(status: AppointmentStatus): string { switch (status) { case 'scheduled': return 'Scheduled'; case 'completed': return 'Completed'; case 'cancelled': return 'Cancelled'; } } This is closely related to how the application presents information.
Depending on its complexity and reuse, it can reasonably remain in the component or be extracted into a presentation utility.
UI state and interaction logic
searchTerm = ''; isFilterPanelOpen = false; toggleFilterPanel(): void { this.isFilterPanelOpen = !this.isFilterPanelOpen; } This is component territory.
A component should understand its own interactions and presentation state.
Extracting every UI-related operation into a service would create unnecessary indirection.
Business logic
Now consider this:
cancelAppointment(appointment: Appointment): void { if (appointment.status === 'cancelled') { return; } if (appointment.date < new Date()) { return; } // Continue with cancellation... } This is different.
Whether an appointment can be cancelled is a business decision.
If the same rule needs to be enforced by multiple components, background processes, or other application workflows, keeping it inside a component becomes problematic.
Business rules should not depend on the component that happens to trigger them.
3. The First Refactoring: Extracting Business Rules
Let's start with a small improvement.
Instead of making the component responsible for determining whether an appointment can be cancelled, we can move that rule into the domain model.
For example:
export type AppointmentStatus = | 'scheduled' | 'completed' | 'cancelled'; export interface Appointment { id: string; vehicleId: string; date: Date; status: AppointmentStatus; } We can introduce a domain function:
export function canCancelAppointment( appointment: Appointment, now: Date, ): boolean { return ( appointment.status !== 'cancelled' && appointment.date > now ); } The component can now use it:
canCancel(appointment: Appointment): boolean { return canCancelAppointment(appointment, new Date()); } This is already an improvement.
The rule is no longer tied to Angular, and it can be tested independently.
describe('canCancelAppointment', () => { const now = new Date('2026-01-01T10:00:00'); it('allows cancellation of future appointments', () => { const appointment: Appointment = { id: '1', vehicleId: 'TRUCK-01', date: new Date('2026-01-01T11:00:00'), status: 'scheduled', }; expect(canCancelAppointment(appointment, now)).toBe(true); }); it('does not allow cancellation of completed appointments', () => { const appointment: Appointment = { id: '1', vehicleId: 'TRUCK-01', date: new Date('2026-01-01T11:00:00'), status: 'completed', }; expect(canCancelAppointment(appointment, now)).toBe(false); }); }); Notice that we don't need Angular's TestBed here.
This is a simple TypeScript function, so a regular unit test is enough.
Also, passing the current time as an argument makes the rule deterministic and easier to test.
This is a small change, but it provides a meaningful improvement in separation of concerns.
4. The Second Refactoring: Extracting API Communication
Let's look at another responsibility.
Our component currently knows how to communicate with the API:
this.http .get<Appointment[]>('/api/appointments') .subscribe({ next: (appointments) => { this.appointments = appointments; }, }); For a small application, this may be fine.
But if multiple components need to retrieve or modify appointments, we probably want to centralize API communication.
A simple Angular service is a reasonable solution.
@Injectable({ providedIn: 'root', }) export class AppointmentApiService { private readonly apiUrl = '/api/appointments'; constructor(private readonly http: HttpClient) {} getAppointments(): Observable<Appointment[]> { return this.http.get<Appointment[]>(this.apiUrl); } cancelAppointment(id: string): Observable<void> { return this.http.post<void>( `${this.apiUrl}/${id}/cancel`, {}, ); } } The component becomes simpler:
@Component({ selector: 'app-appointments', templateUrl: './appointments.component.html', }) export class AppointmentsComponent implements OnInit { appointments: Appointment[] = []; loading = false; errorMessage = ''; constructor( private readonly appointmentApi: AppointmentApiService, ) {} ngOnInit(): void { this.loadAppointments(); } loadAppointments(): void { this.loading = true; this.appointmentApi.getAppointments().subscribe({ next: (appointments) => { this.appointments = appointments; this.loading = false; }, error: () => { this.errorMessage = 'Unable to load appointments.'; this.loading = false; }, }); } } This separation is useful because:
- API communication is centralized.
- Components don't need to know endpoint details.
- The service can be reused.
- API changes have a more predictable impact.
However, notice something important.
The component still manages loading state and error presentation.
And that's perfectly reasonable.
The service handles communication with the API. The component decides how that communication affects the UI.
5. The Trap: Turning Every Service Into a God Object
Here's where I think many refactoring efforts go wrong.
Developers identify a component with too much logic and move everything into a service.
The component becomes smaller.
But the service becomes responsible for everything.
@Injectable({ providedIn: 'root', }) export class AppointmentService { getAppointments(): Observable<Appointment[]> { // API communication } filterAppointments(): Appointment[] { // Filtering } validateAppointment(): boolean { // Business validation } cancelAppointment(): Observable<void> { // Cancellation } calculateAppointmentDuration(): number { // Business calculations } updateAppointmentStatus(): void { // State updates } formatAppointmentDate(): string { // Presentation formatting } } This service is now responsible for:
- API communication.
- Business rules.
- Filtering.
- State management.
- Presentation formatting.
- Application workflows.
We didn't really solve the architectural problem.
We just moved it.
A service with too many unrelated responsibilities can be just as difficult to maintain as an overloaded component.
It can also become harder to test, harder to reuse, and more likely to change for unrelated reasons.
Moving logic into a service is not automatically a refactoring improvement.
The responsibility itself needs to be considered.
6. A Better Approach: Separate Responsibilities, Not Just Files
Let's consider a more intentional structure.
appointments/ ├── domain/ │ ├── appointment.model.ts │ └── appointment.rules.ts │ ├── application/ │ └── cancel-appointment.use-case.ts │ ├── infrastructure/ │ └── appointment-api.service.ts │ └── presentation/ ├── appointments.component.ts └── appointments.component.html This might look like overengineering for a small CRUD feature.
And honestly, it probably would be.
But let's imagine the appointment feature has become more complex.
Cancellation now requires:
- Checking whether the appointment exists.
- Validating whether cancellation is allowed.
- Checking operational restrictions.
- Calling the API.
- Updating application state.
- Notifying other parts of the application.
Now we have a workflow that deserves its own abstraction.
For example:
@Injectable({ providedIn: 'root', }) export class CancelAppointmentUseCase { constructor( private readonly appointmentApi: AppointmentApiService, ) {} execute(appointment: Appointment): Observable<void> { if (!canCancelAppointment(appointment, new Date())) { return throwError( () => new Error('Appointment cannot be cancelled.'), ); } return this.appointmentApi.cancelAppointment( appointment.id, ); } } The component now coordinates the interaction:
cancelAppointment(appointment: Appointment): void { this.cancelAppointmentUseCase .execute(appointment) .subscribe({ next: () => this.loadAppointments(), error: () => { this.errorMessage = 'Unable to cancel appointment.'; }, }); } The component still has logic.
It handles the user interaction, triggers the operation, and updates the UI.
But the business rule and application workflow are no longer embedded in the component.
That's the distinction I care about.
7. When Should You Extract Logic?
I don't think every component needs a use case, a domain layer, or multiple services.
Instead, I usually look for a few practical signals.
Extract logic when:
- It represents a business rule.
- It is reused by multiple components or workflows.
- It coordinates several operations.
- It evolves independently from the UI.
- It is difficult to test in isolation.
- It introduces infrastructure dependencies into business decisions.
- It makes the component difficult to understand.
Keep logic in the component when:
- It directly manages user interactions.
- It controls local UI state.
- It is small and easy to understand.
- It is specific to one presentation.
- Extracting it would introduce unnecessary abstractions.
For example, I wouldn't create a dedicated service just for this:
toggleDetails(): void { this.showDetails = !this.showDetails; } Nor would I create a use case for every small filter interaction.
Sometimes the simplest solution is also the best one.
8. Don't Refactor Based on Line Count
A component with 200 lines isn't necessarily poorly designed.
A component with 40 lines can still have serious architectural problems.
Consider this:
cancelAppointment(): void { // A single method containing: // - business validation // - authorization checks // - API communication // - state updates // - notifications // - error handling } Even if the method is short, it might have too many responsibilities.
On the other hand:
get visibleAppointments(): Appointment[] { return this.appointments.filter( (appointment) => appointment.status !== 'cancelled', ); } This is a simple presentation-oriented transformation.
It may not need to be extracted at all.
The number of lines can be a useful warning sign, but it shouldn't be the architectural decision itself.
I would rather ask:
- Is this responsibility clearly defined?
- Does the component depend on things it shouldn't?
- Can this logic evolve independently?
- Is the code easy to understand and test?
- Would extracting it make the design genuinely better?
These questions are more useful than arbitrary rules such as "components should never exceed 100 lines."
9. Refactor Progressively
One of the biggest mistakes we can make is trying to redesign an entire feature just because a component has become difficult to maintain.
You don't necessarily need to introduce Clean Architecture overnight.
Start with the actual problem.
For example:
Step 1: Extract business rules into pure functions.
Step 2: Move API communication into a dedicated service if it is currently mixed into the component.
Step 3: Separate complex application workflows when they become difficult to understand or test.
Step 4: Introduce interfaces, dependency inversion, or more formal architectural boundaries only when they provide a concrete benefit.
Each step should improve the code in a measurable way.
If a refactoring introduces more files, more abstractions, and more indirection without improving clarity, it might not be worth doing.
Architecture should help us manage complexity, not create additional complexity for its own sake.
Conclusion
Angular components are allowed to contain logic.
They need logic to manage interactions, coordinate UI state, and connect the user interface to application behavior.
The problem starts when they become responsible for business rules, infrastructure details, and complex workflows that should exist independently of the presentation.
But moving everything into services isn't the answer either.
A service can become just another place where unrelated responsibilities accumulate.
For me, the most important question isn't:
"How much logic should an Angular component have?"
It's:
"Does this logic belong to the component, or is the component simply the place where it ended up?"
That distinction is what makes refactoring meaningful.
And sometimes, the right answer is to extract a responsibility.
Other times, it's to leave the code exactly where it is.
The goal isn't to have the smallest components possible.
It's to have code that is easier to understand, test, change, and maintain as the application grows.
What do you think?
How do you decide when an Angular component has become too complex?
Do you prefer keeping most logic in components and services, or do you introduce use cases and domain layers as your features grow?
I'd love to hear how others approach this balance.

