Summary
- Introduction
- A complete, working example
- Implementation choices
- Hexagonal architecture benefits in Angular
- When to use Hexagonal architecture in Angular
- Conclusion
1 - Introduction
For a long time, Model View Controller seemed to be the favorite architecture of software developers. It was used in back-end, as well as in front-end code. But with the growing interest of the community for Domain-Driven Design, this architecture has been challenged by its cousin, the "hexagonal" (or "ports and adapters") architecture.
As MVC, hexagonal architecture uses the separation principle, but also more abstraction, and domain code is central to its architecture.
If you want more information about hexagonal architecture, here is a complete article, written by its designer, Alister Cockburn.
Currently hexagonal architecture is mostly used in back-end code, and there are poor resources about it in front-end code, especially for Angular.
How to adapt hexagonal architecture to Angular ? Would it be beneficial ? If you are also interested in these questions, you should read this article.
2 - A complete, working example
All the following explanations will be based on an exemple app I developed, and made available on Github. This app is based on Angular's tour of heroes. If you launch the app, the displayed interface is the same as in the Angular tutorial, but there are great differences in the code structure. As a reminder, the principle of this small app is to display a list of heroes and to manage (create, delete, modify) them. The angular-in-memory-web-api module is used to simulate external API calls.
This is an overview of this example's architecture:
And the associated code organization:
Domain
In hexagonal architecture, the entire domain related code is isolated. The tour of heroes app has the following purposes: display a list of heroes, display details on a specific hero, and display logs of the actions made by the user. The domain related classes are central to the architecture: HeroesDisplayer
, HeoresDetailDisplayer
and MessagesDisplayer
.
Ports
As you can imagine, domain related code is not lonely in our heroes app. There is also a user interface related code, corresponding to Angular components, and external APIs calls, corresponding to Angular services. In every hexagonal architecture, domain related code doesn't interact directly with all of this code. Instead, objects called ports are used, and they are implemented by interface classes. This weakens the coupling between the elements of our architecture.
In our heroes app, HeroesDisplayer
and HeoresDetailDisplayer
need to interact with an external service, which stores heroes related interactions. For this purpose, they will expose an IManageHeroes
port. For each of our domain classes, we want to keep track of every user's interactions. That is why they also have an IManageMessages
port.
The users realize actual actions in our app through display interfaces. These interfaces can be divided in several categories, according to their purpose. To ensure a faithful comparison with the Angular tour of heroes app, we should have interfaces displaying heroes (a list of heroes and a dashboard), an interface displaying hero details, and an interface displaying messages. Therefore, the related ports should be respectively IDisplayHeroes
, IDisplayHeroDetail
and IDisplayMessages
.
Adapters
Now that our ports are defined, we have to plug adapters on them. One of the benefits of hexagonal architecture is the ease when switching between adapters. For example, the adapter plugged to IManageHeroes
could be an adapter calling a REST API, and we could replace it easily by an adapter using a GraphQL API. In our case, we want our app to be identical to Google tour of heroes app. So we implement an angular service, HeroAdapterService
, calling an in-memory web API, and another, MessageAdapterService
, storing messages locally.
The adapters for the three other ports are user interface related adapters. In our app, they will be implemented by Angular components. As you can see, the IDisplayHeroes
port is implemented by three adapters. Details will be available in the following.
As explained above, there is an asymmetry in our adapters, due to their nature. The architecture diagram represents it in the following way: architecture left-side adapters are designed for users interactions, whereas right-side adapters are designed for external services interactions.
3 - Implementation choices
Because hexagonal architecture was designed for back-end applications, some arrangements have been made in code implementation. These choices are explained in the following part.
Angular related objects in domain code
A good practice in hexagonal architecture is to keep domain related code independent of any framework, in order to ensure it is functional for any kind of adapter. But in our code, the domain is highly dependent on Angular and rxjs objects. In fact, we can assume that we won't use several typescript or JavaScript frameworks, in order to keep interface coherence. Also, the angular dependency injection system is very useful to achieve the inversion of control principle. However, it should be possible to use JavaScript promises instead of rxjs observables, but we would have to write a lot of boilerplate code in our classes.
Observable return type in left-side ports
Since the logic behind the code is handled in the domain, one could wonder why returning Observable objects in IDisplayHeroDetail
, IDisplayHeroes
and IDisplayMessages
ports. Indeed, each object returned by the services is handled inside the domain code using pipe
and tap
methods. For example, the hero detail save result returned by HeroAdapterService
is managed directly in the HeroDetailDisplayer
:
hero-detail-displayer.ts
askHeroNameChange(newHeroName: string): Observable<void> {
[...]
const updatedHero = {id: this.hero.id, name: newHeroName};
return this._heroesManager.updateHero(updatedHero).pipe(
tap(_ => this._messagesManager.add(`updated hero id=${this.hero ? this.hero.id : 0}`)),
catchError(this._errorHandler.handleError<any>(`updateHero id=${this.hero.id}`, this.hero)),
map(hero => {if(this.hero){this.hero.name = hero.name}})
);
}
Still, returning an empty observable from the askHeroNameChange
method is interesting if we aim at enabling the interface adapters to know when the data is loaded. For example, when the hero detail changes are effective, we can go back to the previous page:
hero-detail.component.ts
changeName(newName: string): void {
this.heroDetailDisplayer.askHeroNameChange(newName).pipe(
finalize(() => this.goBack())
).subscribe();
}
The drawback of this implementation choice is the need of subscribing to each domain function call inside left-side adapters:
heroes.component.ts
this.heroesDisplayer.askHeroesList().subscribe();
HeroesDisplayer class instantiated twice
In our app, dependency injection is handled in app.module.ts
. We use injection tokens to make domain classes accessible inside Angular components. For instance the injection of IDisplayHeroDetail into the HeroDetail component is done this way:
app.module.ts
import HeroDetailDisplayer from '../domain/hero-detail-displayer';
providers: [
[...]
{provide: 'IDisplayHeroDetail', useClass: HeroDetailDisplayer},
[...]
}
Sets HeroesDetailDisplayer instance as a IDisplayHeroDetail implementation
hero-detail.component.ts
import IDisplayHeroDetail from 'src/app/domain/ports/i-display-hero-detail';
export class HeroDetailComponent implements OnInit {
constructor(
@Inject('IDisplayHeroDetail') public heroDetailDisplayer: IDisplayHeroDetail,
[...]
) {}
}
Injects HeroDetailDisplayer inside HeroDetailComponent
However, there is a subtlety somewhere in the code: two different injection tokens are generated for the HeroesDisplayer class. Moreover, HeroesComponent
and DashboardComponent
share the same injection token, whereas HeroSearchComponent
component uses another token.
app.module.ts
import HeroesDisplayer from '../domain/heroes-displayer';
providers: [
// Used in HeroesComponent and in DashboardComponent
{provide: 'IDisplayHeroes', useClass: HeroesDisplayer},
// Used in HeroSearchComponent
{provide: 'IDisplayHeroesSearch', useClass: HeroesDisplayer},
]
This is because HeroesComponent
and DashboardComponent
can share the same instance of HeroesDisplayer
: they display the same list of heroes. On the other hand, if HeroSearchComponent
had this same instance, each search would affect the displayed heroes, since the heroes
attribute is modified by the askHeroesFiltered
method in HeroesDisplayer
. Sharing the same token for the three components would change our app's behavior:
4 - Hexagonal architecture benefits in Angular
The main essence of hexagonal architecture consists in having interchangeable adapters allowing our app to be driven equally by a human, a system, or by tests. In our app, we are highly tied to the Angular framework, which means we do not make a whole benefit from this architecture asset. However, I found some promising insights from experiencing it in front-end code.
Decoupled presentational layer, core layer and external services calls
Domain code, corresponding to our core layer, is clearly separated from the interface adapter, namely the presentational layer, by ports. Thanks to these same ports, the risk of adding unwanted code into external service calls is reduced. All the core logic is handled into domain classes.
heroes.component.ts
constructor(
@Inject('IDisplayHeroes') public heroesDisplayer: IDisplayHeroes
) { }
Imports domain class, corresponding to code layer
heroes.component.html
<li *ngFor="let hero of heroesDisplayer.heroes">
[...]
</li>
Uses heroes information handled by domain code inside view, corresponding to presentational layer
Code factorization
If you look at the original tour of heroes application, the main purpose of the HeroesComponent
, of the HeroSearchComponent
and of the DashboardComponent
are very close. They all display the list of heroes, but the possible interactions differ depending on components. Therefore the related core code, mapping services returns to displayed information, should be factorized. In our code, the domain related code for the three components is factorized: we take advantage of hexagonal port re-usability.
Testing
Sometimes, Angular tests can be very painful, even more if core code is mixed with presentational code inside components. This code grows as your application evolves. Keeping our display components, our domain code, and our services isolated from each other make the tests more straightforward. You can easily mock other layers, and focus on testing the current class.
hero-detail.component.spec.ts
beforeEach(async () => {
spyIDisplayHeroDetail = jasmine.createSpyObj(
'IDisplayHeroDetail',
['askHeroDetail', 'askHeroNameChange'],
{hero: {name: '', id: 0}}
);
spyIDisplayHeroDetail.askHeroDetail.and.returnValue(of());
spyIDisplayHeroDetail.askHeroNameChange.and.returnValue(of());
[...]
}
Hero details display tests: domain class and methods can be mocked easily
5 - When to use Hexagonal architecture in Angular
Even if we can't totally compare it with back-end code, hexagonal architecture can have some great benefits in some front-end applications. Some use cases seem particularly adapted to it.
Profile based apps
As we isolated the presentational layer, apps where the same logic is used inside different interfaces, like profile based apps, are good candidates for our architecture. The admin-panel branch gives an example of what the app would look like if we added an admin panel interface. This interface, designed for admin users, allows them to do every administrative action inside a single view: adding, changing, deleting or searching for heroes. Only the AdminPanelComponent
is added to the heroes app, no changes inside domain code or services, showing their reusable attribute.
To launch the administrator interface, run npm run start:admin
on admin-panel
branch.
Apps calling multiple external services
Angular hexagonal architecture is also adapted if you have to contact several external services serving the same purpose. Once again, domain code re-usability simplifies. Let's say we want to call an online service instead of our in-memory heroes service: the superhero api by Yoann Cribier. Adding a SuperheroApiAdapterService
is the only thing required, as you can see in the superhero-api branch.
To make the app communicate with superhero-api, run npm run start:superhero-api
on the superhero-api
branch. Observation: in our example, heroes modifying and deletion aren't implemented by the online service.
6 - Conclusion
This tiny app shows it's possible to adapt hexagonal architecture to an Angular app. Some problems, which haven't been raised by the tour of heroes tutorial app, can be solved using it.
Thank you for reading !
I hope you enjoyed this article, and I'm very interested in any feedback of this architecture adoption.
I also would like to thank Leila for correcting this article.
Top comments (1)
Thanks for sharing π€πΎππΏ