I present to you a new article by
Mark Seemann . It seems that with so many transfers he will soon become a top habreator, even without having an account here!
What is interesting functional architecture? It tends to fall into the so-called “pit of success”, in which developers find themselves in a situation that forces them to write good code.
When discussing object-oriented architecture, we often come across the idea of ​​port and adapter architecture, although we often call it something else: multi-layered, onion, or hexagonal architecture. The idea is to separate business logic from the details of the technical implementation, so that we can vary them independently from each other. This allows us to maneuver in response to changes in business or technology.
')
Ports and adapters
The idea of ​​port and adapter architecture is that the ports represent the boundaries of the application. A port is what interacts with the outside world: user interfaces, message queue, database, files, command line prompts, and so on. While the ports are the application interface for the rest of the world, adapters provide translation between the ports and the application model.
The term "adapter" was chosen successfully, because the role of the adapter (
as a design pattern ) is to provide communication between two different interfaces.
As I explained earlier, you should resort to some port and adapter options if you use Injection Dependency.
However, the problem with this architecture is that it seems that many explanations are needed to implement it:
- my book on Dependency Injection has a volume of 500 pages;
- Robert Martin's book on SOLID principles, package design, components, etc. also takes 700 pages;
- Problem-oriented programming - 500 pages;
- and so on…
In my experience, the implementation of the architecture of ports and adapters is a labor of Sisyphus. It requires a lot of diligence, but if you look away for a moment, the boulder will roll down again.
Implementing the architecture of ports and adapters in object-oriented programming is quite possible, but it requires a lot of effort. Should it be so hard?
Haskell as a study guide
Having a genuine interest in functional programming, I decided to study Haskell. Not that Haskell was the only functional language, but it provides cleanliness at a level that neither F #, Clojure, nor Scala can achieve. In Haskell, a function is pure, unless its type indicates otherwise. This forces you to be careful in the design and to separate the pure functions from the functions with side effects.
If you do not know Haskell, code with side effects can only appear within a specific “context” called IO (I / O). This is a monadic type, but this is not the main thing. The main thing is that by the type of function you can tell if it is clean or not. Function with type
ReservationRendition -> Either Error Reservation
is clean, since
IO
in type is missing. On the other hand, the function with the type:
ConnectionString -> ZonedTime -> IO Int
not clean, because the type it returns is
IO Int
. This means that the return value is an integer, but this integer comes from the context in which it can change between function calls.
There is a fundamental difference between the functions that return
Int
and
IO Int
. In Haskell, any function that returns an
Int
is referenced transparently
en.wikipedia.org/wiki/Referential_transparency . This means that the function is guaranteed to return the same value with the same input. On the other hand, a function that returns
IO Int
does not provide such a guarantee.
In the process of writing Haskell programs, you should strive to maximize the number of pure functions by shifting the unclean code to the boundaries of the system. A good Haskell program has a large core of pure functions and an I / O code shell. Looks familiar, isn't it?
In general, this means that the Haskell type system provides the use of port and adapter architectures. Ports is your I / O code. The core of the application is all your pure functions. The type system automatically pushes you into the "pit of success."
Haskell is a great learning aid because it forces you to clearly distinguish between clean and unclean functions. You can even use it as a tool to check if your F # code is “functional enough”.
F # is primarily a functional language, but it also allows you to write object-oriented or imperative code. If you write your F # code in a “functional” way, it is easy to translate it into Haskell. If your F # code is hard to translate into Haskell, it is probably not functional.
Below is a live example for you.
Acceptance of armor on F #, first attempt
In my
Pluralsight-Test-Driven Development with F # course (the reduced free version is available:
http://www.infoq.com/presentations/mock-fsharp-tdd ) I demonstrate how to implement an HTTP API for an online restaurant booking system accepts reservations. One of the steps in processing a reservation request is to check if there is enough free space in the restaurant to accept the reservation. The function looks like this:
// int // -> (DateTimeOffset -> int) // -> Reservation // -> Result<Reservation,Error> let check capacity getReservedSeats reservation = let reservedSeats = getReservedSeats reservation.Date if capacity < reservation.Quantity + reservedSeats then Failure CapacityExceeded else Success reservation
As follows from the comment, the second argument to
getReservedSeats
is a function of type
DateTimeOffset -> int
. The
check
function calls it to get the number of places already reserved for the requested date.
During unit testing, you can replace a clean function with a stub, for example:
let getReservedSeats _ = 0 let actual = Capacity.check capacity getReservedSeats reservation
And during the final assembly of the application, instead of using a pure function with a fixed return value, you can create an unclean one that queries the database to get the required information:
let imp = Validate.reservation >> bind (Capacity.check 10 (SqlGateway.getReservedSeats connectionString)) >> map (SqlGateway.saveReservation connectionString)
Here
SqlGateway.getReservedSeats connectionString
is a partially used function whose type is
DateTimeOffset -> int
. In F #, you cannot say by type that it is unclean, but I know that this is so because I wrote this function. The function queries the database, so it is not referenced clean.
All this works well in F #, where it depends on you whether a particular function will be clean or unclean. Since
imp
consists of the
Composition root of this application, the impure functions
SqlGateway.getReservedSeats
and
SqlGateway.saveReservation
appear only on the system boundary. The rest of the system is well protected from side effects.
It looks functional, but is it really?
Haskell feedback
To answer this question, I decided to remake the main part of the application in Haskell. My first attempt to check the available seats was directly translated as follows:
checkCapacity :: Int -> (ZonedTime -> Int) -> Reservation -> Either Error Reservation checkCapacity capacity getReservedSeats reservation = let reservedSeats = getReservedSeats $ date reservation in if capacity < quantity reservation + reservedSeats then Left CapacityExceeded else Right reservation
This compiles and at first glance seems promising. Function type
getReservedSeats
-
ZonedTime -> Int
. Since
IO
does not appear anywhere in this type, Haskell ensures that it is clean.
On the other hand, when you need to implement a function to retrieve the number of reserved places from a database, it will inherently have to become unclean, since the return value may change. To enable this in Haskell, the function must be of the following type:
getReservedSeatsFromDB :: ConnectionString -> ZonedTime -> IO Int
Although you can partially apply the first
ConnectionString
argument, the return value will be an
IO Int
, not an
Int
.
A function of type
ZonedTime -> IO Int
is not the same as
ZonedTime -> Int
. Even when running inside an IO context, you cannot convert
ZonedTime -> IO Int
to
ZonedTime -> Int
.
On the other hand, you can call an impure function inside the IO context and extract the
Int
from the
IO Int
. This is not entirely consistent with the
checkCapacity
function
checkCapacity
, so you will need to revise its design. Although the F # code looked “functional enough”, it turns out that this design is not really functional.
If you look closely at the
checkCapacity
function
checkCapacity
, you may wonder why you need to pass a function to determine the number of reserved seats. Why not just pass on that number?
checkCapacity :: Int -> Int -> Reservation -> Either Error Reservation checkCapacity capacity reservedSeats reservation = if capacity < quantity reservation + reservedSeats then Left CapacityExceeded else Right reservation
So much easier. At the system boundary, the application runs in an IO context, allowing you to create clean and unclean functions:
import Control.Monad.Trans (liftIO) import Control.Monad.Trans.Either (EitherT(..), hoistEither) postReservation :: ReservationRendition -> IO (HttpResult ()) postReservation candidate = fmap toHttpResult $ runEitherT $ do r <- hoistEither $ validateReservation candidate i <- liftIO $ getReservedSeatsFromDB connStr $ date r hoistEither $ checkCapacity 10 ir >>= liftIO . saveReservation connStr
(The full source code is available here:
https://gist.github.com/ploeh/c999e2ae2248bd44d775 )
Do not worry if you do not understand all the details of this composition. I described the main points below:
The
postReservation
function receives the
ReservationRendition
input (consider this as a JSON document) and returns
IO (HttpResult ())
.
IO
informs you that all this function is performed in the IO-monad. In other words, the function is unclean. This is not surprising, since it is about the boundary of the system.
Also note that the
liftIO
function
liftIO
called twice. You do not need to understand in detail what it does, but it is necessary to “pull” the value from the IO-type; Ie, for example, to pull out
Int
from
IO Int
. Thus, it becomes clear where the clean code is and where it is not: the
liftIO
function applies to
getReservedSeatsFromDB
and
saveReservation
. This suggests that these two functions are unclean. With the exception method, the remaining functions (
validateReservation
,
checkCapacity
and
toHttpResult
) are clean.
It also raises the question of how to alternate between clean and unclean functions. If you take a
getReservedSeatsFromDB
look, you will see the data being transferred from the clean
validateReservation
function, to the unclean function
getReservedSeatsFromDB
, and then both return values ​​(
r
and
i
) are transferred to the clean
checkCapacity
function and finally to the unclean saveReservation function. All this happens in the
(EitherT Error IO) () do
block, so if any of these functions returns
Left
, the function closes and issues a final error. For a clear and visual introduction to Either type monads, see Scott Wlaschin's excellent article “
Railway oriented programming ” (EN).
The value of this expression is obtained using the built-in function
runEitherT
; and again with this clean feature:
toHttpResult :: Either Error () -> HttpResult () toHttpResult (Left (ValidationError msg)) = BadRequest msg toHttpResult (Left CapacityExceeded) = StatusCode Forbidden toHttpResult (Right ()) = OK ()
The entire
postReservation
function
postReservation
unclean and is located at the system boundary because it handles IO. The same applies to the
getReservedSeatsFromDB
and
saveReservation
. I deliberately put two functions for working with the database at the bottom of the diagram below, so that it seems more familiar to readers who are used to multi-level architectural diagrams. You can imagine that under the circles there are cylindrical objects representing the database.
You can view the
validateReservation
and
toHttpResult
as belonging to the application model. They are clean and translate between external and internal data representation. Finally, if you want, the
checkCapacity
function is part of the domain model of the application.
Most of the design of my first attempt at F # has been preserved, except for the function
Capacity.check
. The re-implementation of the design in Haskell taught me an important lesson that I can now apply to my F # code.
Reception of armor on F #, even more functional.
The required changes are small, so the lesson learned from Haskell is easy to apply to code based on F #. The main culprit was the function
Capacity.check
, which should be implemented as follows:
let check capacity reservedSeats reservation = if capacity < reservation.Quantity + reservedSeats then Failure CapacityExceeded else Success reservation
This not only simplifies the implementation, but also makes the composition a bit more attractive:
let imp = Validate.reservation >> map (fun r -> SqlGateway.getReservedSeats connectionString r.Date, r) >> bind (fun (i, r) -> Capacity.check 10 ir) >> map (SqlGateway.saveReservation connectionString)
This looks a bit more complicated than the Haskell function. The advantage of Haskell is that you can automatically use any type that implements the
Monad
class within the
do
block, and since
(EitherT Error IO) ()
is an instance of
Monad
, the
do
syntax is free.
You can do something similar in F #, but then you have to implement your own computational expression constructor for the Result type. I described it in
my blog .
Summary
A good functional design is equivalent to the “ports and adapters” architecture. If you use Haskell as a criterion for an “ideal” functional architecture, you will see how its clear distinction between clean and unclean functions creates a so-called “success pit”. If you do not write your entire application inside the IO monad, Haskell will automatically reflect the difference and push all communication with the outside world onto the system boundaries.
Some functional languages, such as F #, do not explicitly use this distinction. However, in F # it is easy to unofficially implement it and build applications with unclean functions that are located at the boundaries of the system. Although this distinction is not imposed by the type system, it still seems natural.
If the topic of functional programming is more relevant to you than ever, you will certainly be interested in these reports from our two-day November conference
DotNext 2017 Moscow :