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!
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.
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.
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.
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!
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!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]