Finding the right architecture for my CLI tool - Clean Architecture and FCIS

2026-05-14 | 3 min read


While working on cktop (my own htop tool to monitor my machines) [1], I noticed some architectural friction while implementing features. This tool is just a CLI that shows process data (CPU, memory usage) that’s running on a machine. So it’s not an enterprise application that needs an ultra clever abstraction.

However, very soon I ended up myself mixing “core” logic with UI logic, for example, logic to sort processes by PID, and how I present the sorted slice in terminal. That smelled really bad. So I decided to apply some form of Clean Architecture, where the domain code doesn’t know anything about concrete external dependencies. That alleviated some of the friction, boundaries started to be clearer. Yet, UI started to grow in lines of code, and boundaries started to be less clear again.

Clean Architecture, in my opinion, for a project like this can feel mentally exhausting, like before adding a new logic or feature, I have to think about how it will fit best across multiple layers, dependency inversion, and all the nice words about it, while I like the concepts, it was just slowing me down, again, mentally.

So I found another concept, which apparently isn’t new, it’s been there for more than a decade, but it is certainly new to-me. “Functional Core, Imperative Shell” [2]. Citing:

A functional core should contain pure, testable business logic, which is free of side effects (such as I/O or external state mutation). It operates only on the data it is given.

An imperative shell is responsible for side effects, like database calls and sending emails. It uses the functions in your functional core to perform the business logic.

What I found useful to understand is that FCIS and Clean Architecture aren’t really alternatives, they operate at different levels. CA is about how you organize layers and manage dependency direction. FCIS is about whether functions inside those layers are pure or impure. You can use FCIS within a Clean Architecture.

So that’s basically what I tried to follow within cktop, extracted some “domain/core” logic from the UI to a pure function that can be easily tested and invoked from anywhere. It was easy to determine which part of the logic can be extracted and be “pure”, so the mental-heavy-friction I usually have with Clean Architecture wasn’t there. I am not saying FCIS is going to be always my go-to architectural decision over Clean Architecture, in fact, from what I liked, is that they can co-exist, but definitely a refreshing mental model to not overcomplicate things when shouldn’t. FCIS gave me a clear, compiler-enforceable boundary with no ceremony. For a project this size, that’s enough.

Below is the thought process that I documented while I was trying to ease the friction in cktop [3]:

And this is one of the “pure functions” I extracted from the UI code to the domain layer. No receivers, just domain inputs and outputs being processed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 // FilterProcesses returns the subset of procs whose fields contain query (case-insensitive). 
 // Returns procs unchanged if query is empty. 
 func FilterProcesses(procs []Process, query string) []Process { 
 	if query == "" { 
 		return procs 
 	} 
 	q := strings.ToLower(query) 
 	var out []Process 
 	for _, p := range procs { 
 		if strings.Contains(strings.ToLower(fmt.Sprintf("%d %d %s %s %d", p.Pid, p.Ppid, p.Username, p.Cmdline, p.Rss)), q) { 
 			out = append(out, p) 
 		} 
 	} 
 	return out 
 }

Yes, this is just a strings.Contains call. The point isn’t the complexity of the extracted function. It’s learning to recognize which code has no business knowing about the terminal, the bubbletea framework, or any side effect at all. That recognition is what FCIS gives you.


If this was useful, consider buying me a coffee :)