The Evolution of Extracting Business Logic in Angular
Follow one Angular feature from business logic buried in a UI component through the container/presenter split, a service, and finally pure functions — and see what each step buys you in reusability and test speed.
Single Responsibility Principle and the Separation of Concerns can be a tug of war between writing features quickly and not creating bloated components and overhead that reduces reusability. At first glance, a UI component with some business logic can seem harmless, pretty easy to understand, and still reusable. The problem is UI components are presentational; they shouldn’t care or know what goes into a business decision, and this starting point often leads to large complex components with nested business logic that violates SRP.
I created this chart as a visual aid for the code examples below. On the Y Axis, responsibility increases as a component has more jobs to do, and on the X axis reusability increases as logic is split into its own domains.
The Initial Feature
A profile may have a blue check to show a user is verified, and the initial step may be checking if the user has completed the verification email and added a valid phone number to be verified. We could do that check in the UI component, but what are the long term risks? Our UI component is now tightly coupled to the verification business logic, which has the potential to change in the future. This creates immediate code debt. This also reduces reusability. For example, if I have another type of user called Admin User that requires different verification rules, I can’t use this component without modifying it or duplicating it.
@Component({
selector: 'app-user-status',
template: `<div *ngIf="isVerified()">Account Verified</div>`
})
export class UserStatusComponent {
user = input<{ emailVerified: boolean, phoneNumber?: string }>();
isVerified = computed(() => this.user().emailVerified && !!this.user().phoneNumber);
}
Adding a New Feature
Most developers will be tempted to adjust the component with a quick fix by adding a secondary verification rule that will dramatically increase the complexity of this simple boolean flag:
export class UserStatusComponent {
user = input<{ emailVerified: boolean, phoneNumber?: string, permissions: number[] }>();
isAdmin = input<boolean>();
isVerified = computed(
() => this.isAdmin() ?
this.isAdminVerified() :
this.user().emailVerified && !!this.user().phoneNumber
);
isAdminVerified() {
return this.user().emailVerified &&
!!this.user().phoneNumber &&
this.hasAdminPermissions();
}
hasAdminPermissions() {
return this.user().permissions.some(p => p === 999);
}
}
While this has increased complexity significantly and coupled this component with two different business decisions, it still doesn’t look terrible. But give it time and this component can become a complex decision tree that is very difficult to add new decisions to and is increasingly more difficult to refactor.
Container Presenter Model
Fixing this problem before it starts requires separating the concerns of the business logic and the UI. The easiest first step is splitting this into a business component and a presentational component.
Presentational:
@Component({
selector: 'app-user-status',
template: `<div *ngIf="isVerified()">Account Verified</div>`
})
export class UserStatusComponent {
isVerified = input<boolean>();
}
Business Container:
@Component({
selector: 'app-user-status-container',
template: `<app-user-status [isVerified]="isVerified()"></app-user-status>`
})
export class UserStatusContainerComponent {
user = signal({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [101, 999] });
isAdmin = signal(true);
isVerified = computed(() =>
this.isAdmin()
? this.user().emailVerified && !!this.user().phoneNumber && this.user().permissions.includes(999)
: this.user().emailVerified && !!this.user().phoneNumber
);
}
Now, at least other parts of the app can consume the UI component with their own business logic.
Use a Service
We can take this a step further and move this business logic into a Service, which is a good habit for keeping components thin.
User Status Service:
export class UserStatusService {
isVerified(user: { emailVerified: boolean, phoneNumber?: string, permissions: number[] }, isAdmin: boolean): boolean {
if (!user.emailVerified || !user.phoneNumber) return false;
return isAdmin ? this.hasAdminPermissions(user) : true;
}
private hasAdminPermissions(user: { permissions: number[] }): boolean {
// Could change this to an API check instead of local, like re-authing a token permission
return user.permissions.includes(999);
}
}
Business Container:
export class UserStatusContainerComponent {
user = signal({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [101, 999] });
isAdmin = signal(true);
isVerified = computed(() => this.statusService.isVerified(this.user(), this.isAdmin()));
constructor(private statusService: UserStatusService) {}
}
With these changes, the UI component still stays the same. We can also add a test bed to our service to check these functions.
Spec:
describe('UserStatusService', () => {
let service: UserStatusService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserStatusService);
});
it('should return true for regular user with verified email and phone', () => {
expect(service.isVerified({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [101] }, false)).toBe(true);
});
it('should return true for admin with proper permissions', () => {
expect(service.isVerified({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [999] }, true)).toBe(true);
});
it('should return false if admin lacks permission', () => {
expect(service.isVerified({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [101] }, true)).toBe(false);
});
it('should return false if email is not verified', () => {
expect(service.isVerified({ emailVerified: false, phoneNumber: '123-456-7890', permissions: [999] }, false)).toBe(false);
});
});
Not bad, but now we have a new issue that could cause some bloat and potentially slow down testing: The test bed. While this is not bad or wrong, we can improve the test speed and make them easier to write by improving our service functions.
Pure Functions
By making the functions into pure functions, we can extract them into a utility file, and still consume them in the service or anywhere for that matter. This also means our test can be faster because we don’t need the test bed. We can import and test our functions directly.
Utils:
export const isUserVerified = (user: { emailVerified: boolean, phoneNumber?: string, permissions: number[] }, isAdmin: boolean): boolean => {
if (!user.emailVerified || !user.phoneNumber) return false;
return isAdmin ? user.permissions.includes(999) : true;
}
Service:
export class UserStatusService {
isVerified(user: { emailVerified: boolean, phoneNumber?: string, permissions: number[] }, isAdmin: boolean): boolean {
return isUserVerified(user, isAdmin);
}
}
Spec:
describe('isUserVerified', () => {
it('should return true for regular user with verified email and phone', () => {
expect(isUserVerified({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [101] }, false)).toBe(true);
});
it('should return true for admin with verified email, phone, and permissions', () => {
expect(isUserVerified({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [999] }, true)).toBe(true);
});
it('should return false for admin missing permission 999', () => {
expect(isUserVerified({ emailVerified: true, phoneNumber: '123-456-7890', permissions: [101] }, true)).toBe(false);
});
it('should return false if email is not verified', () => {
expect(isUserVerified({ emailVerified: false, phoneNumber: '123-456-7890', permissions: [999] }, false)).toBe(false);
});
});
It has been a long journey from where we started with our business logic tightly coupled to the UI component. We have extracted business logic to pure functions that are easily readable, testable, and reusable. These functions are consumed by our service, which is consumed by our business component and passed to our UI component. The UI component is completely independent and can be updated with significantly less risk.
While this may not be necessary for every application, or even every aspect of one application, consider the risks of tightly coupled business logic by having a long term view of the application instead of a short term one. Thinking of the horizon will improve code quality, planning, and deliveries. Follow the practices of the Single Responsibility Principle and the Separation of Concerns to ensure long term success, and decouple your UI from your Business Logic. Executing features without a plan is planning to fail.