When working on projects of significant size, developers tend to follow certain principles that help to manage complexity, namely, architecture, making applications easier to understand and expand. While there are endless ways of managing architecture, some popular examples include the Model View Controller (MVC) and hexagonal architecture patterns.
In these patterns, abstractions are set as a high-level system design or architectural blueprint, describing the responsibilities of each module and the relations between them and their dependencies. The right architecture choice will depend on the system’s context, requirements, and whether you need real-time data processing or a monolithic web application.
Keeping the day-to-day development aligned with the architecture blueprint can be challenging, especially if your project or organization is growing quickly. While pull-request reviews, mentoring, documentation, and knowledge sharing may help, these alone may not be enough.
In this article, we’ll discuss the importance of dependencies in the context of TypeScript. We’ll review the potential pitfalls when dependencies are left unchecked, and we’ll propose a solution for keeping our code in sync with the architecture dependency. Let’s get started!
- Dependencies in TypeScript
- The shortcomings of unchecked dependency management
- Adding explicit dependencies with fences
- Dependency boundaries: A practical example
Dependencies in TypeScript
In TypeScript, variables like functions
, objects
, and values
can be imported or exported between files using the ES6 module syntax. Variables annotated with export
will be exported and can be imported using the import
syntax:
// constants.ts export const USER = "Alain"; // logic.ts import { USER } from "./constants"; export const greet = (): string => `Hi ${USER}!`; // ui.ts import { greet } from "./logic"; const html = `<h1>${greet()}</h1>`; // <h1>Hi Alain!</h1>
With this feature, you can break up the application’s functionality into modules, which you can organize by following an architectural blueprint. It’s important to note that importing local files and local or remote packages is possible, like the ones available through npm.
This module syntax allows for great flexibility, imposing no restrictions on what you can import and export. The dependency graphs are implicitly defined across the app.
However, as a project develops, that implicit dependency graph can grow unchecked, causing some issues.
The shortcomings of unchecked dependency management
One of the pitfalls of unchecked dependencies is that any program module can import and create a dependency towards any method exported within the codebase. Private and helper methods can be referenced out of their module, so keeping a public API of a module requires constant manual supervision.
Importing third-party packages poses another trade-off. Third-party modules are great, boosting your development speed and preventing you from reinventing the wheel. However, on the flip side, too many dependencies can expose a project to security issues due to outdated packages, conflicts between packages, and huge bundle sizes.
The third and main issue is that there is no way to programmatically enforce or verify that the code follows the architecture’s dependency rules. Over time, the blueprint and implementation can grow apart to the extent that the reference architecture is not valid anymore, voiding the intrinsic characteristics of an architecture as a result.
For example, in MVC, we can lose the separation between the view and controllers, which contains the business logic, making it hard to test and reducing the ability to iterate the UI without breaking the business logic.
In the next section, we’ll learn how to make our dependencies explicit so that module internals remain private, third-party dependencies are kept under control, and the architecture stays in sync with the code.
Adding explicit dependencies with fences
To make the dependencies between modules explicit and set restrictive dependency rules, we’ll use the good-fences package. good-fences enables you to create and enforce boundaries in TypeScript projects, and it can significantly help mitigate the pitfalls described above.
Let’s learn how to use the good-fences package with an example. We’ll use the concept of fences, provided by good-fences, to ensure that the implementation of the project matches and maintains the planned dependency graph over time.
A fence defines how a module can interact with other modules and fenced directories. We can create a fence by adding a fence.json
file to a TypeScript directory. Fences restrict only what goes through them, like import, export, and external dependencies. Within a fenced directory, there are no module import restrictions. You can also tag fences so that other fence configurations can tag them.
Dependency boundaries: A practical example
The full code for this example is available in the following repo. We’ll use a simple React app that follows the architecture of a store-driven UI, similar to React’s presentational component pattern. The app provides the calculation of the nth number of the Fibonacci or Pell series. Like I said, it’s a simple app.
The UI does not have access to the business-logic methods in the app because they are abstracted behind the store. In addition, the business-logic code does not depend on any UI code, so the UI can evolve without touching the business logic.
Below is the dependency graph between modules. Note that the dependencies between modules are marked with an arrow. The internal modules are colored grey, and the external packages are blue.
To implement the schema above, we’ll create three different fenced directories, math
, store
, and ui
. Each directory maps to one of the modules in the schema.
To prevent other modules or types of either module from reaching into the implementation details, each fenced directory only allows imports from the index.ts
file. Implementation details and helper utils remain safe to change as long as the public APIs defined on the index.ts
files are not modified.
In addition, to prevent circular or unwanted dependencies, like the ui
depending on the logic
directly, each fence is tagged, defining which other fences it can import from.
Finally, to mitigate the issue of unchecked third-party imports, each fence will expressly declare which third-party packages allow imports. To add new packages, you’ll have to modify the fence.json
files to make those dependencies explicit.
The fence configurations for our project are as follows:
// ./math/fence.json { "tags": ["math-module"], "exports": ["index"], "imports": [], "dependencies": [] } // ./store/fence.json { "tags": ["store-module"], "exports": ["index"], "imports": ["math-module"], "dependencies": ["react-redux", "@reduxjs/toolkit"] } // ./ui/fence.json { "tags": ["ui-module"], "imports": ["store-module"], "dependencies": ["react"] }
For an in-depth explanation of the fence configuration options, you can check the official documentation.
All of these rules can be checked programmatically by running the good-fences
npm package, pointing towards the tsconfig.json
file of the project, which is yarn good-fences
. You can now run the checks as part of your CI/CD pipelines or as a commit hooks!
Conclusion
Proper dependency management and following the architectural design during implementation are vital aspects of a healthy and maintainable codebase.
good-fences is not a silver bullet for this complex topic but rather a great tool to have at hand. As your project grows, it is easy to automate manual dependency-rule checking, which encourages the team to be intentional about dependencies. The code is available in the following repo; feel free to change and explore it further. Happy coding!
The post Managing dependency boundaries in TypeScript appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/iW3tzXD
Gain $200 in a week
via Read more