Are you wondering if there is life beyond the conventional layered, clean or hexagonal code architectures? It turns out there is an old concept known as Functional Core, Imperative Shell (FCIS). Let’s take a closer look and see how it could shake things up in constructing maintainable systems.
Traditional code architectures: Challenges
In programming, the execution of code involves a chain of calls between different software components. This process is initiated with a request and concludes with a response, following a request-response model.
To structure and manage code effectively, we use code architectures, such as layered and hexagonal. These models slice our code into different concerns or areas of functionality:
Concerns addressed will differ based on the chosen architectural style and other factors. However, the main objective remains to isolate and modularize key aspects such as data access, presentation, business orchestration, domain or technology, among others.
At the code level, we use specific paradigms, with Object-Oriented Programming (OOP) being one of the most common. OOP introduces unique design patterns, principles, and communication methods.
In addition to the basic request-response model, programming also involves dealing with side effects, especially Input/Output (I/O) operations. These could include tasks like logging, event publishing, external communication or database operations. If they are not appropriately managed, these can become dispersed across the different areas:
As a result, the application workflows can be visualised as a complex network of requests, with data flowing in many directions and side effects spread throughout.
Despite applying all the good practices and patterns, these previous elements could lead our code to directly damage important factors like maintainability, understandability, testability, scalability and overall quality of the code.
Functional Core, Imperative shell: The old kid on the block
Trying to find a different way to architect my code bases, I stumbled upon the Functional Core — Imperative Shell (FCIS) some time ago. This is an aged software design pattern that relies on functional programming to address the common challenges in traditional architectures.
This approach organizes code into two distinct parts:
Functional Core (FC)
The heart of our application, the core, consists of pure functions. These are functions that always produce the same output given the same input and have no side effects (they don’t alter or depend on external states).
The FC contains the logic of the application. Domains, data processing, calculations, or any business rule will live here.
Imperative Shell (IS)
The shell is where any required side effect by the app is handled. This includes database access, attending http requests, logging, interacting with external parties or any orchestration of it.
It acts as a mediator between the external world and the functional core. The shell takes input from external sources, converts it into a form that the core can process, and then takes the core’s output to produce the necessary side effects.
Dependency flow
Dependencies flow inwards, from the shell towards the core. The shell depends on the core, but not vice versa. This means the FC does not have dependencies on frameworks, libraries, or any component defined in the IS.
App workflow
Another important concept of this architecture based on functional programming is how the workflows of the app are composed, instead of complex nets of calls, this architecture aims to have simple function composition, where the output of one function is the inputof the next one and so on:
In FCIS architecture the code flows unidirectionally within the shell and transitions to other components are tightly controlled and self-contained, streamlining the understandability. All side effects and their orchestration are confined in the boundary, the shell, keeping our business logic free from them, enhancing testability and predictability of our code.
Http web server sample
By promoting these principles, the FCIS model helps to build reliable and robust systems, making it a valuable approach in modern software development, especially in complex domains where maintaining clarity and simplicity is challenging.
Show me the code: online Tic-Tac-Toe platform
I guess a lot of questions are popping up at this point about how to implement it, edge cases, code structure … Let’s introduce a practical example to see what this would look like in code.
The example will be an online platform to play Tic Tac Toe:
Here, the workflow for placing a mark in one of the nine spaces:
We have here our workflow for this use-case, consisting on:
- Input side effect: Handling an incoming http request and extracting the incoming params from the external call.
- Prepare the domain: Fetch the chess game from our database, and output side effect, since we depend on an external source.
- Business operation: Call our domain, the tic tac toe game, to do the next movement given the input params, pure code.
- Output side effects: After calling our domain logic, we will perform any side effect required, in our case save it back to the db and write a log.
Code:
@RestController
class PlaceMarkHttpResource(
private val repository: Outputs.GameRepository,
private val writeLogs: Outputs.WriteLogs,
private val placeMark: (TicTacToe, String, Int, Int) -> Either<InvalidMove, TicTacToe> = TicTacToe.Companion::placeMark
)
{
@PostMapping("/games/{gameId}/marks")
fun placeMark(@PathVariable gameId: UUID, @RequestBody request: MarkHttpDto): ResponseEntity<Unit> =
repository.find(gameId)
.flatMap { placeMark(it, request.player, request.row, request.col) }
.onRight { repository.save(it) }
.onRight { writeLogs(it.events) }
.fold({ it.toHttpError() }, { status(CREATED).build() })
}
data class MarkHttpDto(val player: String, val row: Int, val col: Int)
private fun DomainError.toHttpError(): ResponseEntity<Unit> = when (this)
{
GameNotFound -> notFound().build()
is GameAlreadyFinished, PositionAlreadyMarked -> status(CONFLICT).build()
is InvalidMove -> unprocessableEntity().build()
}
Project structure:
tictactoe/
├── core/
│ └── // Domain code, pure and immutable
└── shell/
├── inputs/
│ └── // Input handling (http endpoints, stream consumers, schedulers …) together with workflows of the app
└── outputs/
└── // Any interaction with the external world that is needed (logging, metrics, database, external http clients, stream producers …)
FCIS does not enforce any specific structure, this is a possible simple way to start with.
That’s it, easy and straightforward, please check out the rest of the code if you want to see the full project.
Closing up:
Concluding, while it requires a mindset shift and a commitment to learning, the investment can pay off. So, if you’re looking for a structured yet innovative way to architect your system, FCIS could be just the refreshing change you need.
Resources:
Bonus: Do’s and Don’ts
Do’s:
- Isolate core from side effects: Ensure that your core is rich with pure functions that represent the business rules and logic and keep it free from side effects and external dependencies.
- Keep the imperative shell thin: Use the shell as a mediator/orchestrator, translating external inputs into calls to the functional core and then handling the core’s outputs, it should only handle side effects like IO. Keep it thin and free of any business logic.
- Embrace FCIS without full mastery of FP: FCIS principles can be implemented in conjunction with other paradigms like OOP. Utilize FP constructs such as immutability, pure functions or monads, integrating them into your existing practices.
- Integrate with DDD: Since both are domain centric approaches, FCIS can naturally complement Domain-Driven Design. The functional core can represent a rich domain model, while the imperative shell manages interactions with external aspects.
- Use DIP if needed: Applying the Dependency Inversion Principle (DIP) to external dependencies can be beneficial, but it’s not strictly necessary.
- Ensure team buy-in: Adoption of FCIS should be a collective decision, requiring understanding and commitment across the team.
Don’ts:
- Avoid anemic domain models: Adopting FCIS encourages moving away from anemic tendencies by enriching the core with domain logic. The combination can lead to an architecture that is harder to maintain, understand, and evolve, making it problematic.
- Refrain from adding application-services layer: In FCIS the traditional application service layer is not explicitly avoided, but usually skipped to maintain a pure, side-effect-free Functional Core and a streamlined Imperative Shell. This approach simplifies the architecture, enhancing clarity and maintainability by reducing layers. Introducing a separate “application services” layer or concept might harm the core principles, I would do it only for exceptional cases.
- Don’t use it for everything: Understand that FCIS is not a universal solution suitable for every scenario. In some cases such as projects without business logic, other patterns might fit better.
- Don’t underestimate the learning curve: If the team is not already familiar with functional programming concepts, there can be a significant learning curve, a shift in thinking and practice.
- Don’t ignore error handling: Define an error handling strategy, a functional style such as Railway Oriented Programming with monads fits really well, or adopt other mechanisms such as try-catch blocks, result types, or custom error handling strategies.
- Don’t go by the book: be flexible and pragmatic, there will be exceptions. Treat the architecture as a guide, not a constraint, adapting it to fit the needs and context of your project while maintaining its core principles.