An application produces a binary. It contains the minimal amount of code required to package many libraries to create an artifact that is deployable. All of the application's code is organized into libraries, except for some startup logic and maybe some basic routing.
The application will also define how to build the artifacts that are shipped to the user. Depending on the targets (desktop, mobile or web), we will have separate apps, one app for one target.
A library is a set of files packaged together that is consumed by applications. You can compare them withe node modules (npm packages) or Nuget packages. The libraries can be published to npm or just be bundled with a deployable application as-is.
The purpose of having libraries is to partition your code into smaller units that are easier to maintain and promote code reuse. They have a well-defined public API described inside the index.ts file, which servers as the entry-point of the library. This structure is a fundamental aspect of good software design as it allows splitting into a public and a private part. Other libraries access the public part, so we have to avoid breaking changes as this would affect other parts of the system. However, the private part can be changed at will, as long as the public part stays the same.
Some libraries will only be used by a particular application, but even in this case we prefer to put them in a separate library. The goal of creating them is not code reuse, but factoring the application into well-defined modules to simplify the application's maintenance.
Trough the architecture you will discover we will have multiple types of libraries fulfilling well-defined purposes. These types will be use to limit the interactions between the libraries, which is a requirement for a robust architecture. If we there were no limits, we would have a heap of intermingled libraries where each change would affect all the other libraries, clearly negatively affecting the maintainability. Not only the type of library will restrict the access, also the scope in which it is used. By using tooling like Nx, we will be able to manage this.
A smart component is call smart because they are able to talk to services. This way they have knowledge about the scope or use case they are used in.This makes them also harder to reuse, because they are tied to a scope and services. Smart component will be instantiated trough routing in almost all the cases, that is way it they are sometimes called page or container components. They are responsible to expose the data from service(s) and delegate it to the presentational components. Also do they act on events emitted by the components and delegates it back to the service which will handle the logic. It is recommended to place all view logic into the presentational components and only use the smart component to wire these components together.
In short a smart component wires up one or multiple presentational components, provides them with data and passes emitted events back to a service.
A presentational or dumb component is a component that isn't aware of it's scope or use case. It doesn't communicate using a service, but only have Inputs and Outputs to communicate with the outside world. It has a well-defined purpose to render the data passed trough the input and act on user interactions. The are not responsible for processing these user inputs, rather these components emit events via Outputs to parent components which know how to handle them. Because they are unaware of their scope, the are also highly reusable and are the easiest to test. So may be fully generic like a datepicker others will only be used inside a certain context like a search result of a entity.
A scope is a sub-domain inside the (bounded) context of the application. It groups some functionalities (features, use cases) that are related to each other and can be isolated of other sub-domains withing that same context. Aggregates could be candidates for being a sub-domain. Since managing them can happen on a pretty isolated way.
At the top of our architecture we will have our application. The application is the deliverable that will get deployed and were our end users will interact with. The application will bundle multiple libraries which will be divided by their scopes. Every scope will implement their own layered architecture. Depending on the complexity and the number of features it will contain, the number of layers and libraries will increase. In it's most extend form we will have:
- a (feature)shell that will serve as the entry point of the scope
- multiple feature(s) which implement the use cases of the scope
- an API which exposes functionalities and/or components to other scopes (and is NOT responsible for making http-calls)
- multiple UI libraries that contain the presentational components to build the views
- a domain which holds the domain logic, validations or facades for the features, also the data-access to the back-end is included in here.
- multiple util libraries which contain reusable logic.
But in most cases we can merge several libraries together. The minimal recommended libraries in this case are:
- a (feature)shell that still servers as the entry point of the scope
- a feature that will include the use case implementation(s) together with it's presentational components.
- a domain which holds the domain logic, validations or facades for the features, also the data-access to the back-end is included in here.
By keeping the domain separated we encourage the separation of (business) logic and UI. This is why we keep these separated in it's most simple form.
Other optimization that can be made is putting the presentational components (UI libraries) into the feature libraries, when no presentational components need to be shared between the features.
The shared scope in the architecture can be a part of the application, but in most cases the shared parts, are also the parts that can be reused in other applications. That is why the libraries in the shared scope can be packaged as individual packages so they can be shared over multiple applications. In these cases it's recommended to put them in their own repository and reference them as external packages, so they can have their own lifecycle.
The application layer will serve as an empty shell that will take care of some cross-cutting functionality, and will be responsible for the high level routing towards the feature-shells. To keep this as flexible as possible this will happen on a decoupled way, concrete these feaure-shells will be lazy loaded when access trough routing. The application layer may never contain any functional logic.
An application is also tied to a single domain, this domain will be the entry point for the user that want's to use this application.
Current situation
In the current situation, the starting point will be a deployment monolith. This means the application will be bundled and deployed as a whole. The decoupling between the feature-shells and the applications will be achieved by using the technique of lazy loading of modules available in angular. This will result into a separate bundle for each feature-shell and the application, with the only disadvantage that they can't be build and deployed separately. In this case all the logic to provide this is embedded inside the angular framework. The only constraint is that everything needs to be build together.
Future Situation
In the future the feature-shells will be independently build and deployed. The application will then be responsible to load the latest versions of these bundles and visualize them. The feature-shells will then be bundled as a custom element, this will then be loaded and add to the DOM when the user navigates to the corresponding route. The application will be responsible for this behavior.
- Authentication
- High-level routing
- Handle access to feature-shells
- Global configuration
- Styling/Theming
- Initialization of cross-cutting functionality
- Statemanagement
- Translations
- General error handling
- Unauthorized
- Forbidden
- NotFound
An application can only access:
- feature-shells (on a lazy-loaded decoupled way)
- util libraries in the shared scope
- ui libraries in the shared scope
The feature-shell layer is the entry point of a subsystems that groups feature (aka use case) logical together. It is responsible for initializing the subsystem and provides and coordinates the access different features. Oppose to the application layer, the feature shell layer will be coupled tightly with his features. A library is created for every subsystem that is available in your application.
To identify these subsystems we can take advantage of the strategic Design describes in Domain Driven Design. The goal of strategic design is to identify self- contained domains. For each domain a feature-shell should be created. In the future, when we move to a mirco front-end architecture, these feature-shells will be exposed as custom-elements that can standalone. The only thing they need is an application that hosts them (on a lazy-loaded way)
To identify these subsystems we can take advantage of the strategic Design describes in Domain Driven Design. The goal of strategic design is to identify self- contained domains. For each domain a feature-shell should be created.
- Authorization
- Feature routing
- Feature configuration
A feature-shell can only access:
- features in their own scope
- ui libraries in their own scope
- util libraries in their own scope
- ui libraries in the shared scope
- util libraries in the shared scopee
The feature layers contains the feature / use case implementations using smart components. These smart components will be reference in the router and will bring the presentation of the data (views) and the logic (facade services) together.
- Implement a specific use case / feature with a smart component
- The smart component(s) provides dumb components with data and act on there actions
- The smart component(s) communicate with the facade service that implement the use case logic
- The smart component(s) provide global state to the facade service
- Router params
- User state
A feature can only access:
- the domain of their own scope
- ui libraries in their own scope
- util libraries in their own scope
- ui libraries in the shared scope
- util libraries in the shared scope
The Api library provides access to the subsystem for features that are present in other scopes. This should be avoid as much as possible, as this would result into a tight coupling between multiple subsystems. On the other hand it serves as a solution to avoid to have to much code placed in a shared library.
The Api library is NOT the one that handles the communication with the Back-end services, but servers as an entry-point for other scopes to use functionality of the current scope.
- Expose (controlled) access to features in other subsystems.
An Api libary can only access:
- the domain of their own scope
- ui libraries in their own scope
- util libraries in their own scope
An Api library must be explicitly granted to individual scopes
A Ui libarary is a collection of related presentational components. These components get consumed by the smart components inside the features.
There are multiple reasons why you should or shouldn't group presentational components. When creating them inside a scope, grouping based on functionality will be the most obvious choice. In the shared scope other criteria will be taken into account. In this case we will group them rather on the function it will serve in the application (ex. base-layout in a layout library, date-picker in a forms library). Also other requirements should be taken into account, for example if you have components that relay on third-party libraries, it would be wise to put them in a separate library. This way we can minimize the dependencies when we don't need them.
- Group related reusable components
An Ui libary can only access:
- util libraries in their own scope
- ui libraries in the shared scope
- util libraries in the shared scope
The domain layer serves as the hart of your subsystem, since it holds the (domain) logic and is responsible for the communication with the back-end system. All of this will be hidden behind facades, which will isolate the (domain) logic and represent it in a use case-specific manner. Together with the fact that it is crucial to architecturally separate infrastructure requirement from the actual (domain) logic, we will subdivide the domain layer in to 3 parts:
- application services/facades, will be consumed by the smart components
- actual domain, which will contain the data-structures
- infrastructure, will take care of data-access
Of course, we can package these parts in their own libraries. But for the sake of simplicity, it is also possible to store them in a single subdivided library. These subdivisions makes sense if these parts are ordinarily used together and only exchanged for unit tests.
The application services or facades are used to represent the domain logic in a use case specific way. They come with several advantages
- Encapsulate complexity
- Taking care of state management
- Simplified API's
- Decouple UI and logic.
Using the facade pattern we provide a simplified interface for every specific use case. Inside the facade is where the magic happens. It is responsible for calling the backend services when necessary, (re)fetching data, keeping state, ... It serves as the single source of truth for the smart component using it, and it should also be the only service it depends on for providing data and handling actions.
To make our design more reactive we will expose our data as Observables. This way the facade can auto-deliver updated information when the conditions change. This way the facade is in full control when and how the data is delivered. This also reverses the way we are used to handle data, instead of the classic pull-based model (the views will explicitly call service methods to force-reload (aka ‘pull’) the data.),
we can evolve to a push-based model (latest version of the data is pushed to the views)
Besides performance improvements, using observables provides a further advantage. Observables allow further decoupling since the sender and receiver do not have to know each other directly. This structure also perfectly fits DDD, where the use of domain events are now part of the architecture. If something interesting happens in one part of the application, it sends a domain event, and other application parts can react.
An other advantage of encapsulating the logic inside a facade, is that it allows to introduce a state-management framework when needed without affecting any external smart component. For the consumer of the facade it is irrelevant whether it manages the state by itself or by delegating to a state-management library.
In here we will place the domain logic, validation, entities, ... we need for our features and use cases. In here we won't use the typical OO approach where we would have entities that encapsulate data and business rules, and where the entity is responsible to ensure the state remains consistent by adding private fields and public methods that operate on them. With languages such as Typescript object-orientation is less critical, it is a multi-paradigm language in which functional programming plays a major role.
Functional programming splits the classic OO entity model into data and logic parts. The entities in Functional Programming also use public properties, and excessive use of getters and setters which only delegate to private properties, is often ridiculed. The state is kept consistent simply by making the data structures preferentially immutable. This is easily done by adding the read-only keyword before the fields. This means that any part of the program that seeks to change such objects has first to clone it. If other parts of the program have first validated an object for their purpose, they can assume that it remains valid. A pleasant side effect of using immutable data structures is that it optimizes change detection performance. Deep-comparisons are no longer required. Instead, a changed object is a new instance, and this the object references are no longer the same.
In an SPA, infrastructure concerns are – generally – asynchronous communication with the server and data exchanges. So in here we provide services which enables us to fetch data from the server and call actions on the server.
- Provide application services/facades features
- Make API calls to the backend
- Keep track of the state of the entities
- Validate the actions performed by the features
The domain libary can only access:
- util libraries in their own scope
- util libraries in the shared scope
In most case Util libraries will be present in the shared scope of a project. As the name describes the provide code that is reusable in many parts of the application. Examples of this are pipes which format data in a view, helper functions, ... But they can also be bigger like a logger or handle authentication across systems. The only constraint is that they aren't tied to the UI. For this we have the UI libraries to handle this.
Provide reusable functionality
In theory util libraries could depend on other util libraries, however this should be avoid since this would make the dependency tree more complex.
Util classes can be used by all other type of libraries