Original cover photo by Issy Bailey on Unsplash.
The scope of this piece
In the course of building intricate applications, we lean heavily on front-end frameworks — React, Vue, Angular, and the like. On top of those, we layer UI kits, testing suites, and a host of micro-libraries that address niche concerns. The list grows, and so does the convenience. These tools often solve recurring problems elegantly, almost magically, out of the box. Naturally, a spirited debate exists about whether adopting third-party solutions is wise — weighing costs, developer experience, user experience, and the ever-expanding size of node_modules.
However, this article does not wade into that particular discussion.
Then what is the focus?
The focus here is on what happens after we've already committed to these tools and want them to work together with minimal friction. Once the initial thrill of seeing our problems evaporate fades, we're confronted with a hard truth: every framework and library (all of them!) comes with its own set of constraints and expectations. This leads to recurring situations like the following:
- We set out to build a feature.
- To do so, we intend to leverage a component, service, or function from an external library.
- We hit a snag: the library either lacks support for the use case, contains a bug, or demands a mountain of boilerplate to accomplish what we need.
- This sparks a dilemma: do we scrap the feature, question our choice of framework (and is that even feasible?), or find a way to adapt and work around the issue?
This is just one illustration; other variations abound. Perhaps the library has a genuine defect, or the desired feature requires us to go against the explicit guidance of the library's core maintainers. The list goes on.
Charting a path forward
Let's examine some strategies available to us when facing these challenges, specifically within the Angular ecosystem and its related tooling.
- Plan ahead, but
- Don't overengineer
- Keep future possibilities in mind and create room to implement them when the time is right
- Refrain from building features or components merely on the off-chance they might be useful someday — only do so when the need is certain
- Favor extending existing functionality over wrapping it
- If extension isn't viable and wrapping is unavoidable, do so with maximum transparency, steering clear of monkey-patching
- If monkey-patching becomes absolutely necessary, make sure to document it thoroughly. We'll dedicate a section to this topic
With that outline in mind, let’s dive into each of these points in depth.
1. Anticipating requirements
Suppose you're kicking off a fresh project and are in the market for an Angular UI library. The options are plentiful — Angular Material, PrimeNG, NgBootstrap, and others. We won't debate which one reigns supreme — that's a futile argument — but rather, we'll lay out some guiding principles to inform your choice.
- Select a UI library that aligns most closely with your envisioned design — whether it exists in your head, a Figma file, or elsewhere. It's unrealistic to expect a library to perfectly match your desired aesthetic. You will inevitably need to customize some components, so choose one that minimizes that effort.
- If you foresee significant modifications, lean towards a library that is easy to tweak. However, be cautious: you don't want to end up with a
styles.scssfile containing thousands of lines of overrides. - For customization, explore the tools provided by the library or its community for creating custom themes. For instance, Angular Material has a theme generator that produces a custom theme you can integrate into your project.
- When customizing, utilize the code-level hooks the library offers. For example, PrimeNG components typically expose an
Inputproperty that accepts a CSS class for styling.
This example focuses on UI libraries, but the logic applies to other tools as well: ensure whatever you pick is a good fit, extensible, and easy to adapt.
2. Steering clear of excessive complexity
This point is simple: don't overengineer. For a small application, reinventing a modest wheel might be more practical than wrestling with a full-featured library that brings its own baggage and potential paradigm shifts. Conversely, for a large application, don't instinctively reach for a monolithic tool that tackles a dozen problems with countless bells and whistles. Sometimes, a focused library that solves a single issue precisely is the optimal route.
3. Keeping options open
This scenario is so common it suggests a recurring pattern:
- A developer crafts a component, directive, or service tailored to a specific feature.
- They implement only the bare essentials.
- They tie it too tightly to that one feature.
- When a similar need arises elsewhere, reusing it is nearly impossible. Future developers either duplicate the solution with tweaks or introduce unwieldy configuration objects that are difficult to understand or document.
Alternatively, to sidestep that mess, someone might preemptively build a giant configuration object and keep piling on complexity. So, how do we avoid this, especially in Angular?
- Consider whether the functionality could be useful down the line. If you think "yes," move to the next step.
- Can you implement this with a directive instead of a component? Directives are frequently overlooked and pack more punch than people realize — see this article by Tim Deschryver for insights. Directives are also simpler than components in general and easier to reuse.
- Be cautious with dependency injection. I don't mean ban it outright, but be mindful. If you inject a business-specific service into a component or directive intended for reuse, it becomes tightly coupled to only certain parts of your app. Using a generic service like a
localStoragewrapper is fine, whereas injecting aUserServicemight undermine reusability.
4. Curbing feature creep
As we noted in the initial points, future planning is good, but we must balance it against the reality that more features lead to more complexity, more bugs, and more maintenance. So, how do we find the right equilibrium without going overboard?
- Angular's dependency injection can tempt us into wrapping third-party services with our own, tailored APIs. We'll revisit this in the next section, but for now: only write wrapper methods that you actually use. Don't feel compelled to wrap every single method from the underlying library.
- If you notice a component or directive behaves drastically different based on a prop (often a
booleanlikeisSomething), consider splitting it into two separate components or directives. This is a telltale sign of a component doing too much. Recall the single responsibility principle. - Avoid combining functionality in your own services, but do combine it when wrapping third-party libraries. For example, if a library provides separate methods for setting data and toggling a loading state, and you almost always call them together, wrap them as one method in your service. Conversely, if you're writing the original code, keep methods separate, since you can't predict future usage patterns.
5. Extending functionality
When we talk about extending functionality, we are not referring to traditional OOP class inheritance. For instance, beginning with Angular v15, hostDirectives enables you to expand existing directives (details available here), so take advantage of mechanisms like this one. Leverage dependency injection to assemble capabilities, rather than to tightly bind components/directives/services together.
When dealing with third-party components, particularly UI libraries, there is a common urge to encapsulate them inside our own components and set defaults for certain inputs. Consider this code sample:
<div>
<third-party-dialog
[open]="isOpen"
[modal]="true"
[closable]="true"
[closeOnEsc]="true">
<app-other-component/>
</third-party-dialog>
</div>
The obvious move might be to wrap that component inside one of our own:
@Component({
selector: 'app-dialog',
template: `
<div>
<third-party-dialog
[open]="isOpen"
[modal]="modal"
[closable]="closable"
[closeOnEsc]="closeOnEsc">
<ng-content></ng-content>
</third-party-dialog>
</div>
`
})
export class DialogComponent {
@Input() isOpen: boolean;
@Input() modal: boolean = true;
@Input() closable: boolean = true;
@Input() closeOnEsc: boolean = true;
}
And employ it in this manner:
<div>
<app-dialog [isOpen]="true">
<app-other-component/>
</app-dialog>
</div>
This clearly functions and offers a modest improvement over the initial approach; for example, there is no longer a need to supply every input value repeatedly, and the amount of boilerplate decreases.
Yet, a few issues arise:
- To stay future-proof, every input from the third-party component must be declared and forwarded in the wrapper. A typical UI library component likely exposes many inputs
- Outputs present another hurdle — events emitted by the library component do not bubble up to the consumer, so they need to be caught and re-dispatched manually
- If either of the previous points is missed, and a feature wants to use a "forgotten" input/output, that feature now also demands an update to the wrapper
To bypass these difficulties, the better route is to augment the component through a directive, following the approach outlined in the previously mentioned article by Tim Deschryver:
@Directive({
selector: 'third-party-dialog'
})
export class DialogDirective {
constructor(
private dialog: ThirdPartyDialogComponent,
) {
if (dialog.modal === undefined) {
dialog.modal = true;
}
if (dialog.closable === undefined) {
dialog.closable = true;
}
if (dialog.closeOnEsc === undefined) {
dialog.closeOnEsc = true;
}
}
}
In this pattern, we essentially latch onto the ThirdPartyDialogComponent and assign default values to its inputs only when they haven't been supplied. As a result, there is no need to forward every input, and event re-emission becomes entirely unnecessary:
<div>
<third-party-dialog
[open]="isOpen"
(someEventNotHandledInTheDirective)="handleEvent($event)">
<app-other-component/>
</third-party-dialog>
</div>
Another well-known illustration of this principle in Angular is HTTP Interceptors. There is a frequent temptation to wrap the HttpClient service within a custom implementation to add features like custom headers, cookie management, validation, and so forth. However, intercepting requests/responses and layering in behavior "on the fly" is sufficient in the vast majority of cases. The following interceptor appends the client's timezone offset as a header on every outgoing request:
@Injectable()
export class TimezoneInterceptor implements HttpInterceptor {
intercept(
request: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
const timezoneOffset = new Date().getTimezoneOffset();
const modifiedRequest = request.clone({
headers: request.headers.set(
'X-Timezone-Offset',
timezoneOffset.toString(),
),
});
return next.handle(modifiedRequest);
}
}
Requests can be shaped in this way across numerous scenarios, further illustrating that wrapping even Angular services is rarely the optimal approach.
6. Wrapping? Wrapping!
But once we've exhausted the option of extending through framework mechanisms — say it's not feasible — do we wrap? Or do we use the service directly? I maintain that wrapping is the right choice.
Consider these points:
- We can shape a more fitting API for our purposes — maybe the method names don't sit well with our project's conventions, and wrapping lets us smooth over that (admittedly minor) friction
- When the third-party service lacks certain capabilities, or we want to tweak its behavior, the wrapper is the perfect place to incorporate those adjustments
- It future-proofs our codebase: if the library becomes unmaintained, or we opt to swap to a different one, only the wrapper needs to change — everything else stays untouched
- Unit testing becomes simpler since we can mock the wrapper with ease, eliminating the need to mock external dependencies
If those reasons resonate, then we need a set of ground rules for crafting the wrapper:
- Keep the wrapper as lean as possible — mostly a straightforward pass-through that calls the third-party service and returns the outcome (possibly with light modifications)
- No business logic belongs here — the wrapper serves purely as a gateway, keeping it generic and reusable across the app
- Test it thoroughly — comprehensive unit tests will prevent headaches down the line
- Never monkey patch the third-party service — extend its capabilities rather than altering its existing behavior. This keeps things clear for future maintainers and avoids unintentional side effects or bugs being introduced into the dependency
Here's a service that wraps the localStorage API for illustration:
@Injectable()
export class LocalStorageService {
get(key: string): string | null {
return localStorage.getItem(key);
}
set(key: string, data: string) {
localStorage.setItem(key, data);
}
remove(key: string) {
localStorage.removeItem(key);
}
clear() {
localStorage.clear();
}
has(key: string): boolean {
return this.get(key) !== null;
}
get length(): number {
return localStorage.length;
}
}
As you can observe, the wrapper is exceptionally thin — it merely delegates to localStorage, renames a couple of methods to shorter forms for convenience, and introduces a has method. Most wrappers you create will follow this same pattern.
With those rules in place, we can finally tackle the most challenging problem of all.
7. When monkey-patching is the only option left
Occasionally, though, we run into a genuinely difficult situation — a third-party dependency contains a defect that blocks a critical feature and no workaround exists. So what should we do then? Below is a checklist to work through before you reach for a monkey-patch.
- Verify whether the problem reproduces across different environments, users, or machines
- Confirm whether newer releases of the library still exhibit the same behavior
- Look for an existing GitHub issue and any ongoing discussion about it
- Re-evaluate whether your own requirements can be adjusted to avoid the problematic part of the library entirely
- File a new issue on GitHub and monitor the discussion that follows
- Consider submitting a pull request with a fix — the community benefits, and so do you
- Explore whether extending, wrapping, or otherwise intercepting the library's behavior could resolve the problem
If every one of those avenues fails to produce a solution (the library is abandoned, maintainers reject your PR, the deadline is tight, and so forth), then monkey-patching becomes justifiable. After you apply the patch, proceed with these follow-up steps:
-
Document the reasoning and mechanics thoroughly:
- Explain the defect itself
- Reference the relevant GitHub issue, if one exists
- If a future release promises to address the problem, link to that commitment
- Add a
// TODOcomment marking the patch for removal - Describe precisely how the monkey-patch is implemented
Build comprehensive unit tests for the patch itself — mock the rest of the library's behavior to isolate the fix
Create a backlog item that tracks the eventual removal of this temporary measure
NEVER embed business logic inside the monkey-patch — doing so makes future removal nearly impossible and creates the tightest possible coupling you can imagine.
Here is a real-world example of patching a defect in the PrimeNG component library:
Note — this is taken from production code and is fairly involved, so the exact details of the fix are intentionally left out. The emphasis here is on the documentation style and overall strategy:
/**
* @description
* This directive is for fixing a bug on PrimeNG
* (github link to issue)
* InputNumber component to alow add -(minus)
* sign with prefix attribute. <br>
* This works by monkey-patching the InputNumber component's
* insert and update methods.
*
* TODO: Remove this directive when the bug is fixed in PrimeNG
* <b>Author:</b> Maybe put your name so
* people know who to address if they have questions
*/
@Directive({
selector: 'p-inputNumber', // find all the p-inputNumber elements
})
export class InputNumberDirective implements AfterContentInit {
constructor(private inputNumber: InputNumber) {}
ngAfterContentInit() {
// first we keep the native methods
// we want to monkey-patch bound to the instance
const updateInput = this.inputNumber.updateInput.bind(
this.inputNumber,
);
const insert = this.inputNumber.insert.bind(this.inputNumber);
// now we monkey patch it
this.inputNumber.insert = (
event,
text,
sign = {
isDecimalSign: false,
isMinusSign: false
}
) => {
// we do some monkey patching logic
// Prevent the prefix sign from being deleted
// some heavy lifting
// we call the native method then
insert(event, text, { ...sign, isMinusSign: false });
}
this.inputNumber.updateInput = (
value,
insertedValueStr,
operation,
) => {
// perform the native method first
updateInput(value, insertedValueStr, operation);
// preventing navigation of cursor
// to the end of input after entering negative value
// a bunch of other heavy lifting
};
}
}
That covers this topic.
Wrapping up
Integrating libraries and frameworks into your project can be both enjoyable and demanding. In this article we have walked through a variety of scenarios you might face when doing so in Angular. One final thought — remember that no piece of advice is universally correct, so always weigh the specifics of your own situation before acting.
