Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

Explicit Design, Part 4. Ports, Adapters, and Infrastructure

Posted on Aug 7 • Originally published at bespoyasov.me Let's continue the series of posts and experiments about explicit software design. Last time we created UI components and discussed the interaction between the UI and the Application core. In this post, we will prepare the project infrastructure: create a store for the data model and a service for API requests.But first, disclaimer This is not a recommendation on how to write or not write code. I am not aiming to “show the correct way of coding” because everything depends on the specific project and its goals. My goal in this series is to try to apply the principles from various books in a (fairly over-engineered) frontend application to understand where the scope of their applicability ends, how useful they are, and whether they pay off. You can read more about my motivation in the introduction.Take the code examples in this series sceptically, not as a direct development guide, but as a source of ideas that may be useful to you.By the way, all the source code for this series is available on GitHub. Give these repo a star if you like the series!If previously the UI was communicating with the application through input ports, then with the infrastructure, the application will communicate through output ports: the types FetchRates, ReadConverter, and SaveConverter.Output ports are the “levers” on the “other side” of the application core. They describe what “service” functionality the application core needs to solve a particular task.The core relies on these types and “orchestrates” the work of use cases, initiating the work of necessary services at the right moments.Since the application core relies on abstract types, it becomes decoupled and independent of the infrastructure. Such deliberately explicit separation of code is often excessive and not needed, but we are trying to follow the principles and guidelines from programming books, so we will leave everything completely decoupled.The first service that we will need is a module for communicating with the API server. We could describe the Function for making requests to the server like this:The get function constructs the URL of the API endpoint, runs the browser's fetch function under the hood, and parses the data from the server's JSON response.Note that the code of this function is generic. It does not know what data will come from the server, except that it will be JSON.The distinctive feature of services is that they are conceptually not related to the domain and can work with any application. A function like get can move from project to project, residing in some lib or shared folder.Services typically solve a specific utility task: network communication, internationalization, reading and writing to local storage, etc. In the case of the get function, we can check this by describing its type:The ApiRequest type does not touch high-level application concepts. It expresses itself in low-level terms: “request,” “API,” “URL.” It doesn't even know what kind of data it will get from the API, instead it uses the R type-argument, which says that specific data for this function is not important. What matters is the scheme of operation and communication with the server.Because of their “genericness”, services can be reused in different projects:Obviously, such a reusable service will not work exactly as our application core wants it to. To resolve this conflict, we will write an adapter—a function that will transform this service's interface to the type of the application's output port.We can divide all the work of the adapter for the API service into 3 stages:Let's assume that we know that the server sends us data in the following format:Then the work of an adapter can be expressed as a sequence of the following transformations:Let's write a function fetchRates that will implement the FetchRates type:...And now let's implement each step.Let's start with something simple: since we know in what format the server returns the response, we can write a function to transform the data format.In this function, we access the value of the required field in the server response and return it. In real projects, deserialization can be much more complicated depending on the server response and model data format. (We may need to rename fields or, for example, enrich them with data from another request.)The purpose of the toDomain function is to encapsulate the knowledge of how to convert server data into a model. When such a deserializer is explicitly separated in the code, it is easier for us to find the place where we need to make changes if the shape of the data on the server changes.Moreover, with the explicitly defined deserialization, we can support multiple API response schemas simultaneously:Again, whether to make deserialization explicit or not depends on the task, project size, and how volatile the data on the server is. If the server response never changes, it's probably not necessary.The response from the server that we describe as the ServerRates type is a so-called data transfer object, DTO. We will not delve into this concept in detail, but Scott Wlaschin has a dedicated chapter on deserialization and working with DTOs in the book “Domain Modeling Made Functional.” I highly recommend reading it.Using the deserializer, we can fill in the 2nd step of the fetchRates function:Next, we will call the service itself and retrieve the data from the API:Notice that we keep the endpoint URL directly in this module, and not in the network service. The reason for this is that the service should remain reusable and independent of the domain and specific project.The specific endpoint URL is part of a particular feature of the current project. Knowledge of where to retrieve data for the converter from should be kept nearby to the converter itself so that we can quickly locate the necessary places for updates. This increases the cohesion of the feature, as it does not scatter knowledge of it across different parts of the application.Such a “knowledge packaging” is also known as a vertical slice. We will discuss this architectural pattern in one of the upcoming posts 🙃Overall, this implementation is already sufficient to integrate the API call with the core of the application. Accessing this adapter will make it call the service, transform the data into the required format, and return it.To test such an adapter, we need to create a mock for the ~/services/network module and verify that the get function is called with the required parameters.Using mocks to replace dependencies is a valid option in JS and TS code. In real tests of functions that work with side effects, we will most likely see mocks. However, if our goal is to make the function dependencies explicit, we can “bake” them in.Again, the code we will be writing from now on is somewhat non-standard™. We will write it this way to demonstrate the idea of loose coupling and explicit dependencies. It's probably not a good idea to write production code in exactly the same way unless you have good reasons to do so.We will discuss when it's justified to write code this way and when it's not. For now, please keep in mind that we are writing demonstration code from which you can draw ideas for contemplation, but not necessarily do everything exactly the same way.In the world of OOP, the idea of “substituting” the necessary dependencies at the right moment is the basis of dependency injection. In general terms, the idea is to free a module from the need to import specific dependencies and work with the reliance on their guarantees—public interfaces.This way, modules become decoupled from each other, and “glued” together via a DI container. The DI container automatically injects the required concrete dependencies into the places where their interfaces are declared. In object-oriented code, this helps solve the problem of composing objects and their related side effects.Dependency injection is actually a special case of inversion of control, which helps to make the code more flexible. I wrote a separate article about the idea of inversion of control, dependency injection, and how to do it in an object-oriented style.Unlike in OOP, in more functional code, all dependencies are passed explicitly, so we won't be using a DI container. Instead, we'll use partial application and the Factory pattern to “bake in” the dependencies.It doesn't mean, however, that a DI container can't be used together with a near-functional approach. This is more of a matter of taste and tooling but, for simplicity, we'll go with the “raw” approach without additional tools.Also note that in pure functional code, the concept of “dependencies” cannot exist in the first place, because any dependency brings with it a side effect, and functional code should be pure. All side effects (and therefore dependencies) in such code will be located at the edges of the application.We do not follow this approach only for the sake of convenience and easier understanding. If you are interested in how to compose a React application in a purely functional style, let me know!In one of our previous posts, we used the fact that inside a function, we can refer to its arguments and use them, relying on their types:Dependencies were passed as the last argument in this variant:However, such a “dependency injection” approach raises a contradiction: we either need to always require the argument with dependencies to be passed, or make it optional. Neither option is convenient or sufficiently reliable.This contradiction can be resolved by making the function “remember” the references to its dependencies:In fact, we can “put” the dependencies in a closure of an outer function:And then partially apply the function createChangeQuoteCode to get a function with “remembered” dependencies:The technique is called partial application because we execute a function of the form a -> b -> result “halfway,” as if stopping along the way and getting b -> result as a result. A little more about this is written on learnyouhaskell and on Scott Wlaschin's website.This technique of working with dependencies is sometimes called “baking” dependencies. It is exactly what we'll use to prepare the adapter for the API.To “bake” the dependencies, we will create a factory function that takes the adapter dependencies and returns FetchRates:In the code, we can express this as:Then, to create and configure an adapter, we will call the factory and pass an object with the actual service to it:Note that the function returned by createFetchRates depends only on the types of services. We perform the substitution of specific service implementations separately, during composition. The intention (functionality of the function) and the implementation (composition) are separated again, making modules more independent.Let's take a closer look at the structure and composition of the module. We can notice that the implementation of the factory and its result—the function that implements the input port—depends only on two things:This “isolation” from other modules through abstraction helps to avoid unnecessary coupling. The module has one clear entry point for other modules—the public interface:This entry point reduces coupling and stops the spread of changes across the codebase, because other modules no longer need to know the internals of this module, and vice versa.On the other hand, substituting concrete implementations increases coupling, and this happens at the final stage, during the composition of the module:Finally, to hide what should not be exposed, we can configure re-exports through index.ts:Then, from all the module internal details:...The other modules can access only the contents of api.composition.ts.It's clear that in JS you cannot completely forbid importing something from specific files. However, firstly, it expresses intention and structure, and secondly, the restriction can be enforced with a linter or other tools if desired.On the other hand, such purism may not be necessary for a real project. Remember that the code is only experimental 🙃In addition to requests to the API, we also need a runtime storage for loaded exchange rates and values entered by users. Today we will write an implementation of the store using the Context API, but in the future we will look at more suitable tools and libraries for this task.Let's start with an overview of the required functionality. The core of the application requires 2 functions from the store: for reading and saving model data.To implement this, we can create a context with this type:Then, we'll create a provider:Implementing a store in this way will lead to unnecessary re-renders of components that depend on it. In real projects, try to avoid using context as a state manager and use appropriate libraries for this purpose.Now we need to associate the types of application ports with the implementation of specific functions by “registering” the service:Note that we made the decision about which technology to use for the storage service only at the very end, when the application core was ready. This is a sign of fewer artificial constraints on our choice of tools. When we make a decision about the tooling, we already know a lot more about the project and can choose a more appropriate library for our tasks.In one of the next posts, we will look at how to choose tools if there are constraints that have been discovered during the design phase, and how loose coupling between modules can help us.In addition to updating the converter via SaveConverter, we also need to read data from the store in the UI:Since the application core is not involved in reading data (we do not transform data with domain functions when reading), we can implement input ports for reading directly in the store service:Such a “fast track” bypassing the application core can be used in applications where there is little or no domain logic.Personally, I don't see anything wrong with this implementation because the service is still connected to the rest of the application through abstractions, so the coupling between modules almost doesn't increase.The application is almost ready. We have created the domain model and worked out use cases, created the UI layer and the necessary components, created services for API requests and data storage. Now we need to put all this together into a working project, which is what we will do next time.Today we implemented all the infrastructure of the application and connected it to the application's output ports. In the next post, we will compose the entire application from its parts, using hooks as a way of composition, and also discuss what other ways can be used for it.Links to books, articles, and other materials I mentioned in this post.P.S. This post was originally published at bespoyasov.me. Subscribe to my blog to read posts like this earlier!Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well Confirm For further actions, you may consider blocking this person and/or reporting abuse Michael Di Prisco - Jun 6 Micael Levi L. C. - Jul 9 Matt Angelosanto - Jul 6 Roy Weru - Jul 9 Once suspended, bespoyasov will not be able to comment or publish posts until their suspension is removed. Once unsuspended, bespoyasov will be able to comment and publish posts again. Once unpublished, all posts by bespoyasov will become hidden and only accessible to themselves. If bespoyasov is not suspended, they can still re-publish their posts from their dashboard. Note: Once unpublished, this post will become invisible to the public and only accessible to Alex Bespoyasov. They can still re-publish the post if they are not suspended. Thanks for keeping DEV Community safe. Here is what you can do to flag bespoyasov: bespoyasov consistently posts content that violates DEV Community's code of conduct because it is harassing, offensive or spammy. Unflagging bespoyasov will restore default visibility to their posts. DEV Community — A constructive and inclusive social network for software developers. With you every step of your journey. Built on Forem — the open source software that powers DEV and other inclusive communities.Made with love and Ruby on Rails. DEV Community © 2016 - 2023. We're a place where coders share, stay up-to-date and grow their careers.



This post first appeared on VedVyas Articles, please read the originial post: here

Share the post

Explicit Design, Part 4. Ports, Adapters, and Infrastructure

×

Subscribe to Vedvyas Articles

Get updates delivered right to your inbox!

Thank you for your subscription

×