How Functional Programming Architecture Works

30.03.2025

I come from a self-taught background and everything I learned so far about scalable architecture I have learned from sources such as (1, 2): I mainly follow SOLID and Composability. Intuitively I have developed a sensibility for abstraction which led to follow my own personal style of planning a program and how I approach the development workflow. I have always embraced functional programming as a tool inside my mostly object-oriented program, helping me to express core functionality in a reusable way.
My domain, which is creative applications usually is built on Systems like Game Engines, or other creative frameworks which mostly use object-oriented semantics. I have also studied Haskell to have a completely new perspective on how to conceptualize programming semantics in a purely functional style.

However I was never able to draw the final connection between all the benefits that functional programming allegedly provides with my mostly imperative and object-oriented understanding of architecture. So even if I wanted to try for myself to build a completely functional program I would not have the theory to back it up.
Most articles focus on patterns, which for me are just the building blocks for architecture. Common principles to follow to implement parts of your full architecture. Here is an Example. However what these kind of articles fail to address is how to structure the entire project from it's main entry point down to all the modules, modelling domain specific semantics and enable scalable functionality which grows with the ideas and requirements.

This is what this article wants to address. How are functional programs structured?
I have found the following talks which shed some light to this question:

What both have in common is a segregation of the code base in impure functions, functions that have side-effects and interact with outside systems or internal state (what Eric Normand calls Actions) and pure functions, or functions that have no external dependencies, one input maps to one output, and no matter of the state of the system for the same value x the same value y is produced. These functions behave like the common operators *, + or && (Which Eric Normand calls Calculations). Actions are very hard to reason about or to test, because they often involve external systems or very specific state (conditions to take place).

So functional programming suggests to minimize Actions and maximize Calculations. This also facilitates building your logic from one domain out of the components of another domain. Reaching a stratified design where the more you move down, the more generic, reusable and prone to less change components become, and the more you move up the more specific, and prone to changes components become. This results in an Onion Architecture. And this does not imply that you have to Cry (well... maybe you have when you first deal with Monads to express effectful Calculations). But that your architecture becomes this nicely layered whole, where each layer represents its own domain, beginning from the core, adding more specific layers to express your application all the way to the outer shell as entrypoint.

Mark Seemann discusses how sticking to functional programming can lead to very intuitive extension decisions, which feel like "pits" (due to how little resistance they present to change, or effort is needed as opposed to a hill), and calls some patterns functional programming enforces "pits of success". Some of these pits:

  1. Ports and Adapters <- Architecture type (Onion)
  2. Values, Services and Objects <- Data and Behavior
  3. Testability (Isolation of testable Units)

The first point reiterates the onion architecture also covered by Eric Normand, however I would like to stress the example Seemann makes, in which he shows how a usual business flow always involves the interleaved interaction of pure and impure functions: Validate (pure) -> Check reservation in DB (impure) -> Decision (pure) -> Save Reservation in DB (impure) -> Create Response (pure); as an example.
Again here the idea that he proposes is that impure functions can call pure functions, but not the other way around. So the impure functions sit on the boundaries of the system taking care of all the interfaces to the outside world, reaching down into the core to make these decisions. This principle to keep all the entrypoints in the single impure shell shielding the pure domain, is what Seemann equalizes to Ports and Adapters.

In Object-oriented programming we follow the notion that objects are data with behavior, and through encapsulation we hide internal logic while exposing services. This is a common accepted pattern in enterprise software design. And you end up adding more and more behavior as the class attracts them, which in turn leads to the anti-pattern of the god class.
Then Seemann goes on into domain-driven design and how in OO we separate between Entities that are the nouns describing the components of the domain, and value objects that just contain data without much business logic/behavior. These are usually put into Services, objects containing the main logic accessible to other objects. This is valuable knowledge in of itself when there is no other choice than to use object-oriented design.

This pattern comes natural to functional programming as it only has data and functions.
Data in functional programming, is just data. Data contains values in a certain structure. Even though you can place functions inside Data these are never executed internally.
While on the other hand pure functions which the type system is built around promote simple value to value mappings.

And here he says one critical sentence "Just by programming Haskell you learn to do what a 400 page book tries to teach you for C#". If you'd like to learn more about how we can learn from idiomatism of languages, I would suggest this article of mine.

His last point is then, how a Module doubles down as a name space and to a class-like (service) replacement. Inside one module you define the internal data types and all the Calculations you can do with that data type.

His last point is about testability, which can not really be evaluated in our case, with static analysis. And is out of the scope for our purpose. But it's essence is, that in OO the code that needs to be tested is often wrapped in bigger classes with a lot of boiler plate, while in a functional language the logic we want to test is probably contained in one single pure function.

To make the theoretical concepts tangible and applicable I am going to choose a functional programming project which I will analyze and will then try to represent with a layered onion diagram.

Language: Haskell

Why Haskell?

One core rule that becomes apparent when dealing with the onion architecture how important it is to maximize pure functions (Calculations) while minimizing impure functions (Actions) and how just the reference inside a pure function to an impure function can turn the entire call stack impure. Haskell does not allow this. Haskell enforces this clear separation by design, and therefore analyzing a Haskell code base already guarantees that we will be able to observe the discussed theory. Also as we will see, it is very easy to spot an Action (an impure function) inside of Haskell.

So I will hop on GitHub enter language:haskell and explore the existing projects.
On second thought... I am already pretty familiar with one project, which will be the perfect fit.

Project: Tidal Cycles

Why TidalCycles?

I chose tidal cycles, because I am already familiar with it's application, helping me to contextualize modules. It is a music live coding (domain specific) "language" which qualifies it as an application of the creative domain. And while writing this article I am also extending Tidal with my own Library to allow musical composition, from a more classical perspective. Therefore analyzing tidal's architecture will benefit me twice.
But the most important aspect, tidal at its core is pure, it gives you a huge collection of tools to produce, edit and iterate musical patterns which are then sent to an external system called SuperCollider as signals to be fed into Synthesizers and Audio Processing to actually output sound. It therefore has a clear separation of Calculations and Actions on the conceptual level. And tidal's source code is complex enough to warrant an analysis.

Code_OZ9OwID4te.png

This is the root folder of Tidal Cycles. Here we already encounter a lot of different build environment dependencies and project level configuration which we are not interested for in this analysis. These kind of explorative analyses I do in other dedicated Study Sessions.
I am interested in executable code, purely written in Haskell and how modules are organized.

From Main.hs down the Dependency Tree

For that reason we will hop straight into source folders. Our first station is main/Main.hs where we will resolve the architecture from the top-down, meaning we will look at the main process as the most exterior layer and peel off layer by layer to reach the core. Simply put: main, to work needs to have full access to all modules - directly or indirectly, so it seems a productive strategy to uncover relationships progressively starting from there. This also implies that the amount of modules to cover will grow exponentially the further we move down the dependency tree, I will therefore not cover every little nook and cranny of every module. Just the ones necessary to complete my analysis methodology.

Feel free to skip this section, which will be very long and mostly help me to get an overview of the depencency structure for the next step. But I think it will an interesting read, as I will encounter the modules for the same time and give a bit of context to each, explaining some Haskell mechanisms.

Also what needs to be mentioned is a file called BootTidal.hs which is invoked by the GHCi when starting the REPL to evaluate tidal code as the user. As far as I am aware from my current understanding. Main.hs will start and manage the main engine that communicates with SuperCollider, while BootTidal.hs exposes all the types and operators to work with to the User.

For every file I visit I will list the import statements and the definitions inside the file. I will omit the import statements to external libraries as they give us no further insight into the project's architecture. I will first list the internal definitions and then end with the imported modules to build a logical connection between modules.

Once we have a list of the source code we can then move on to actually analyzing it by applying ideas from the talks.

Main.hs

This is the main Entry point. Every Haskell program begins with
main :: IO ()
The elegant feature of Haskell is that the type system is so expressive, that you can already understand a lot from just the function signature. This is an IO (input/output) operation of type void. Meaning it does not really return a value and it depends on external boundaries.
Every main function has this signature.
This main function starts tidal and creates a SuperDirt target (the module inside SuperCollider) with a Config. It then runs an Interpreter with some error handling.

core :: C.Stream -> I.InterpreterT IO ()
Core processes a stream (which is the tidal engine) and builds the environment for the Interpreter loading the necessary modules. As I have already stated, the main usage of tidal is as domain-specific language to execute musical patterns in the editor through the REPL.

message :: Handle -> String -> I.InterpreterT IO ()
This is a wrapper to bind some monadic actions to the stdout of the System used inside core to print some feedback to the user.

work :: C.Stream -> String -> I.InterpreterT IO ()
Is the unit to take the tidal engine, pass it a string of commands and process them

safe_list :: Int -> [a] -> [a] -> [a]
second :: Int
blocks :: [String] -> [[String]]
block :: [String] -> ([String], [String])

The remaining functions are Calculations (which based on my first intuition should reside in a sub module expressing the domain of interpreter formatting). How do I recognize them as pure functions just by the signature? They don't have the IO type in the signature.
Did I already tell you that Haskell is very good to observe these kind of ideas?


Sound.Tidal.Safe.Context as C
Most of the core and main functions come from inside Context which seems to be the container of all the Tidal semantics.

src\Sound\Tidal\Safe\Context.hs

Context seems to be an index file of most of the Tidal specific source code. It imports all the other modules, exposes certain functions and values and defines a lot of syntactic abstractions through the Op type.

newtype Op r = Op (ReaderT Stream IO r)
  deriving (Functor, Applicative, Monad, MonadCatch, MonadThrow)

Based on it's application Op seems to be some kind of wrapper type allowing to combine a Reader, a Stream and IO .

exec :: Stream -> Op r -> IO r

The layout of Op seems to be an expected format for the Reader monad from the standard library and all what exec does, is pass in the Stream with the operation wrapper.


Sound.Tidal.Stream.Main (startTidal)
Sound.Tidal.Stream.Target (superdirtTarget)
Sound.Tidal.Stream.Config as C
Sound.Tidal.Stream.Types (Target (..))
Sound.Tidal.Control as C
Sound.Tidal.Core as C
Sound.Tidal.Params as C
Sound.Tidal.ParseBP as C
Sound.Tidal.Pattern as C
Sound.Tidal.Scales as C
Sound.Tidal.Stepwise as C
Sound.Tidal.Simple as C
Sound.Tidal.UI as C
Sound.Tidal.Version as C

Essentially here all the submodules are bundled into the C namespace and passed to main.

src\Sound\Tidal\Stream\Main.hs

This is the reason I started to follow the dependency down beginning with Main.hs, as now we have found the true Main.hs. What I mean by that: this is the core entrypoint of the engine defining the tidal process as a stream. We have walked from the application layer into the tidal stream layer.

startTidal :: Target -> Config -> IO Stream
Which is just passing the config to the next function
startStream :: Config -> [(Target, [OSC])] -> IO Stream
startMulti :: [Target] -> Config -> IO ()


Sound.Tidal.Clock as Clock
Sound.Tidal.Stream.Listen
Sound.Tidal.Stream.Process
(Sound.Tidal.Stream.Target)
(Sound.Tidal.Stream.Types)
(Sound.Tidal.Version)
(Sound.Tidal.Stream.Config)

I have listed here the new dependencies, and already encountered dependencies in parenthesis.

src\Sound\Tidal\Stream\Target.hs

This is the actual bridge between tidal and SuperDirt.

getCXs :: Config -> [(Target, [OSC])] -> IO [Cx]
resolve :: String -> Int -> IO N.AddrInfo
handshake :: Cx -> Config -> IO ()
recvMessagesTimeout :: (O.Transport t) => Double -> t -> IO [O.Message]
send :: Cx -> Double -> Double -> (Double, Bool, O.Message) -> IO ()
sendBndl :: Bool -> Cx -> O.Bundle -> IO ()
sendO :: Bool -> Cx -> O.Message -> IO ()
These are some Actions communicating with the SuperCollider interface

superdirtTarget :: Target
superdirtShape :: OSC
dirtTarget :: Target
dirtShape :: OSC
These define the data shape and the message format for the OSC protocol

sDefault :: String -> Maybe Value
fDefault :: Double -> Maybe Value
rDefault :: Rational -> Maybe Value
iDefault :: Int -> Maybe Value
bDefault :: Bool -> Maybe Value
xDefault :: [Word8] -> Maybe Value
these are constructors for default values in the format of the internal value wrapper


(Sound.Tidal.Pattern)
(Sound.Tidal.Stream.Config)
(Sound.Tidal.Stream.Type)

No new dependencies were introduced here

src\Sound\Tidal\Stream\Config.hs

data Config = Config
Our first Data definition

defaultConfig :: Config
verbose :: Config -> String -> IO ()
An unexpected action

setFrameTimespan :: Double -> Config -> Config
setProcessAhead :: Double -> Config -> Config
Some pure setters, that produce an amended config


(Sound.Tidal.Clock as Clock)

src\Sound\Tidal\Stream\Types.hs

Well would you know it, the main type declaration module for the Stream system.

data Stream
data Cx
data StampStyle
data Schedule
data Target
data Args
data OSC
data PlayState
type PatId
type PlayMap


Sound.Tidal.Show ()
(Sound.Tidal.Pattern)
(Sound.Tidal.Stream.Config)

It is surprising finding any dependencies on this level. But this implies that some of the data definitions actually require knowledge of the core domain.

tidal-core\src\Sound\Tidal\Control.hs

First thing to note, we are now in a totally different base directory. This can be an indication that we have moved into a new layer of abstraction.

spin :: Pattern Int -> ControlPattern -> ControlPattern
_spin :: Int -> ControlPattern -> ControlPattern
chop :: Pattern Int -> ControlPattern -> ControlPattern
chopArc :: Arc -> Int -> [Arc]
_chop :: Int -> ControlPattern -> ControlPattern
striate :: Pattern Int -> ControlPattern -> ControlPattern
_striate :: Int -> ControlPattern -> ControlPattern
mergePlayRange :: (Double, Double) -> ValueMap -> ValueMap
striateBy :: Pattern Int -> Pattern Double -> ControlPattern -> ControlPattern
striate' :: Pattern Int -> Pattern Double -> ControlPattern -> ControlPattern
_striateBy :: Int -> Double -> ControlPattern -> ControlPattern
gap :: Pattern Int -> ControlPattern -> ControlPattern
_gap :: Int -> ControlPattern -> ControlPattern
weave :: Time -> ControlPattern -> [ControlPattern] -> ControlPattern
weaveWith :: Time -> Pattern a -> [Pattern a -> Pattern a] -> Pattern a
weave' :: Time -> Pattern a -> [Pattern a -> Pattern a] -> Pattern a
interlace :: ControlPattern -> ControlPattern -> ControlPattern
slice :: Pattern Int -> Pattern Int -> ControlPattern -> ControlPattern
_slice :: Int -> Int -> ControlPattern -> ControlPattern
randslice :: Pattern Int -> ControlPattern -> ControlPattern
_splice :: Int -> Pattern Int -> ControlPattern -> Pattern (Map.Map String Value)
splice :: Pattern Int -> Pattern Int -> ControlPattern -> Pattern (Map.Map String Value)
loopAt :: Pattern Time -> ControlPattern -> ControlPattern
hurry :: Pattern Rational -> ControlPattern -> ControlPattern
smash :: Pattern Int -> [Pattern Time] -> ControlPattern -> Pattern ValueMap
smash' :: Int -> [Pattern Time] -> ControlPattern -> ControlPattern
echo :: Pattern Integer -> Pattern Rational -> Pattern Double -> ControlPattern -> ControlPattern
_echo :: Integer -> Rational -> Double -> ControlPattern -> ControlPattern
echoWith :: Pattern Int -> Pattern Time -> (Pattern a -> Pattern a) -> Pattern a -> Pattern a
_echoWith :: (Num n, Ord n) => n -> Time -> (Pattern a -> Pattern a) -> Pattern a -> Pattern a
stut :: Pattern Integer -> Pattern Double -> Pattern Rational -> ControlPattern -> ControlPattern
_stut :: Integer -> Double -> Rational -> ControlPattern -> ControlPattern
stutWith :: Pattern Int -> Pattern Time -> (Pattern a -> Pattern a) -> Pattern a -> Pattern a
_stutWith :: (Num n, Ord n) => n -> Time -> (Pattern a -> Pattern a) -> Pattern a -> Pattern a
stut' :: Pattern Int -> Pattern Time -> (Pattern a -> Pattern a) -> Pattern a -> Pattern a
sec :: (Fractional a) => Pattern a -> Pattern a
msec :: (Fractional a) => Pattern a -> Pattern a
trigger :: Pattern a -> Pattern a
qtrigger :: Pattern a -> Pattern a
qt :: Pattern a -> Pattern a
ctrigger :: Pattern a -> Pattern a
rtrigger :: Pattern a -> Pattern a
ftrigger :: Pattern a -> Pattern a
mtrigger :: Int -> Pattern a -> Pattern a
mt :: Int -> Pattern a -> Pattern a
triggerWith :: (Time -> Time) -> Pattern a -> Pattern a
splat :: Pattern Int -> ControlPattern -> ControlPattern -> ControlPattern

Remember how I said initially, that tidal presents a rich language to create and edit patterns? Well this is part of it. Also notice how the core use of the (domain-specific) language is pure. There is no Action, no IO and yet they all express "action" and transformation as Calculations. They also gravitate around one type: ControlPattern. Another design pattern we can notice is the use of the prefix _ for what seems to be private (unexposed) functions and the suffix ' for function variants.


Sound.Tidal.Params as P
Sound.Tidal.Pattern.Types (patternTimeID)
(Sound.Tidal.Core)
(Sound.Tidal.Pattern)
(Sound.Tidal.UI (bite, _irand))

tidal-core\src\Sound\Tidal\Core.hs

To avoid having to copy and paste as many function signatures as in Control.hs I will limit my listing to the number of definitions, and try to identify any central Type this revolves around to distinguish it from Control.

Core has many more definitions than Control. With 200+ definitions it makes clear, why it is called core. Also most definitions return Pattern a or some other subtype of a. Also half of the definitions are some syntactic sugar for wrapper types. The entire module is pure as it was the case with Control. Not only does it define pure functions, it defines operators like |+|, which in Haskell semantics are essentially functions themselves.

Also this is a beautiful showcase how inside the pure domain, layers still do exist. To get from Control to Core we peel off the *Control from ControlPattern to get Pattern. Like with the Onion. This example illustrates it perfectly. Pattern is also one of the core abstractions of TidalCycles. We will discover the others in other modules.


(Sound.Tidal.Pattern)

Well this single import showcases succinctly my last observation from above, Pattern is the core dependency. So typical for Haskell.

tidal-core\src\Sound\Tidal\Params.hs

Over 1000+ Calculation definitions, of what seems more fundamental wrappers, like mapping drum name strings to numeric values, resolving sound samples, etc. It gravitates around ControlPattern and Pattern ValueMap which are essentially the same. To be very honest this module presents more definitions than what I expected. Many functions defined here I would expect in Control. As the documentary comment claims "Params.hs - Provides the basic control patterns available to TidalCycles by default".


Sound.Tidal.Utils
(Sound.Tidal.Core ((#)))
(Sound.Tidal.Pattern)

tidal-core\src\Sound\Tidal\ParseBP.hs

"ParseBP.hs - Parser for Tidal's "mini-notation", inspired by Bernard Bel's BP2 (Bol Processor 2) system." Mini-notation in Tidal is what makes tidal cycles so expressive when writing patterns. You can express notes, scale degrees, sample patterns, alternating parts, accumulative effects and rhythmic organization with a few characters inside a string.
This module presents a lot of custom parsing types and String related definitions. Everything is pure.


Sound.Tidal.Chords
(Sound.Tidal.Core)
(Sound.Tidal.Pattern)
(Sound.Tidal.UI)
(Sound.Tidal.Utils (fromRight))

tidal-core\src\Sound\Tidal\Pattern.hs

This module defines the core abstraction Pattern as

data Pattern a = Pattern {query :: State -> [Event a], steps :: Maybe (Pattern Rational), pureValue :: Maybe a}
  deriving (Generic, Functor)

instance Applicative Pattern where
  pure :: a -> Pattern a
  pure v = Pattern q (Just 1) (Just v)
    where
      q (State a _) =
        map
          ( \a' ->
              Event
                (Context [])
                (Just a')
                (sect a a')
                v
          )
          $ cycleArcsInArc a

type ControlPattern = Pattern ValueMap

instance Eq (Pattern a) where
  (==) :: Pattern a -> Pattern a -> Bool
  (==) = noOv "(==)"

instance (Ord a) => Ord (Pattern a) where
  min :: (Ord a) => Pattern a -> Pattern a -> Pattern a
  min = liftA2 min
  max :: (Ord a) => Pattern a -> Pattern a -> Pattern a
  max = liftA2 max
  compare :: (Ord a) => Pattern a -> Pattern a -> Ordering
  compare = noOv "compare"
  (<=) :: (Ord a) => Pattern a -> Pattern a -> Bool
  (<=) = noOv "(<=)"

instance (Num a) => Num (Pattern a) where
  negate :: (Num a) => Pattern a -> Pattern a
  negate = fmap negate
  (+) :: (Num a) => Pattern a -> Pattern a -> Pattern a
  (+) = liftA2 (+)
  (*) :: (Num a) => Pattern a -> Pattern a -> Pattern a
  (*) = liftA2 (*)
  fromInteger :: (Num a) => Integer -> Pattern a
  fromInteger = pure . fromInteger
  abs :: (Num a) => Pattern a -> Pattern a
  abs = fmap abs
  signum :: (Num a) => Pattern a -> Pattern a
  signum = fmap signum

...

instance Monoid (Pattern a) where
  mempty :: Pattern a
  mempty = empty

instance Semigroup (Pattern a) where
  (<>) :: Pattern a -> Pattern a -> Pattern a
  (<>) !p !p' = pattern $ \st -> query p st ++ query p' st

instance Monad Pattern where
  return = pure
  p >>= f = unwrap (f <$> p)

unwrap :: Pattern (Pattern a) -> Pattern a

...

instance (Num a, Ord a) => Real (Pattern a) where
  toRational :: (Num a, Ord a) => Pattern a -> Rational
  toRational = noOv "toRational"

instance (Integral a) => Integral (Pattern a) where
  quot :: (Integral a) => Pattern a -> Pattern a -> Pattern a
  quot = liftA2 quot
  rem :: (Integral a) => Pattern a -> Pattern a -> Pattern a
  rem = liftA2 rem
  div :: (Integral a) => Pattern a -> Pattern a -> Pattern a
  div = liftA2 div
  mod :: (Integral a) => Pattern a -> Pattern a -> Pattern a
  mod = liftA2 mod
  toInteger :: (Integral a) => Pattern a -> Integer
  toInteger = noOv "toInteger"
  quotRem :: (Integral a) => Pattern a -> Pattern a -> (Pattern a, Pattern a)
  x `quotRem` y = (x `quot` y, x `rem` y)
  divMod :: (Integral a) => Pattern a -> Pattern a -> (Pattern a, Pattern a)
  x `divMod` y = (x `div` y, x `mod` y)

...

empty :: Pattern a
empty = Pattern {query = const [], steps = Just 1, pureValue = Nothing}

silence :: Pattern a
silence = empty

type Event a = EventF (ArcF Time) a

data Value
  = VS {svalue :: String}
  | VF {fvalue :: Double}
  | VN {nvalue :: Note}
  | VR {rvalue :: Rational}
  | VI {ivalue :: Int}
  | VB {bvalue :: Bool}
  | VX {xvalue :: [Word8]} -- Used for OSC 'blobs'
  | VPattern {pvalue :: Pattern Value}
  | VList {lvalue :: [Value]}
  | VState {statevalue :: ValueMap -> (ValueMap, Value)}
  deriving (Typeable, Generic)

...

newtype Note = Note {unNote :: Double}
  deriving (Typeable, Data, Generic, Eq, Ord, Enum, Num, Fractional, Floating, Real, RealFrac)

So what are we seeing here?

This is what I meant by core abstraction. Think of this as the most fundamental Class in your object oriented programming code. This module is all about expressing what a pattern is, how it behaves (Eq), how do you work with it (Num, Integral) and access it's internals without ever having to know anything about it's structure (Functor, Applicative, Monad). This module also showcases the full range of abilities and expression forms when defining types.

Types define what something is, and what operations can be done with it. This is like interfaces in OO to achieve polymorphism. However in functional programming there is only functions, pure functions that is. Instead types are organized in type classes that expect definitions for certain functions. All of them are Calculations , however I see them in two domains. One domain deals with making your custom type behave like existing base types. Show class makes your type printable as a string, Num and Fractional type make your type behave like a number, Eq and Ord make your type comparable and orderable in lists for example. Then there is Monoid, Functor, Applicative and Monad which are structural type classes. They are less about how your type behaves, but instead about how to access internals of your type. In a sense structural type classes are what make Haskell, what it is. In OO you have a similar principle where you expose public functions that allows external code to interact with the internal state in a controlled manner. In Haskell instead you define an abstract higher-order function that allows you to pass any process that operates on the internal makeup of your data type, without any knowledge of it's internals. How to merge two instances of your type. How to apply functions to your type. How to apply functions wrapped in your type, and how to chain operations in the context of your type. This is what the IO Type allows.

Wrapped values?

Here comes the other pillar of the Haskell type system. Algebraic types: Product Types, and Sum Types. Or in simpler terms what we know in other languages as Struct (Product types, because all the possibilities your type can express is given by a multiplication of all the possible values of it's members) and Enums (Sum types, which behave like addition, as the total possible values it can have is equal to the sum of all the possible values it members can have). However other than in OO or imperative languages they allow you to express their structure as direct assignment. For example Pattern a is a custom type that takes any value of type a (an abstract type variable, comparable to typescript's any) and it contains a query function that given a state (State) returns a list of Events of your internal value ([Event a]) and the steps that might exist, or not (Maybe Pattern Rational). Here we also see another powerful pattern, recursive type definition, defining a member of pattern in terms of a pattern. Certain type declarations are so common that the compiler can automatically infer certain type classes, like Generic and Functor (the ability to apply a function to the internal value a of pattern).

Also this module defines what a ControlPattern is, namely a Pattern of ValueMap which is not defined in this Module. Instead we have definitions for Note and Value, a custom polymorphic value wrapper to encapsulate values inside the message that is sent to SuperDirt (SuperCollider). Value is an example of a Sumtype, in which each entry is to be read as OR, and other than Enums Sumtypes allow you to have type construction inside the enumeration of options, which allows to add a lot of additional information to each option.

It is this potent type system that allows for the paradigm to work exclusively with pure functions. Types allow you to express any effect you need. Types can be implicit contexts like a log, or a config file, an IO operation (for Actions). It can be a complex data structure with internal layout (which you would usually model as a class) but giving you the mechanisms to interact with them without needing any internal knowledge. It allows you to mix and match types, by defining one type in the domain of other type classes. But most importantly it allows you to compose types into more complex types.


Sound.Tidal.Time

Time is the only dependency, which suggests an even more fundamental abstraction.
Time is also used to express Event which is an EventF inside the Arc of Time of a.

tidal-core\src\Sound\Tidal\Time.hs

This is the core abstraction for time

type Time = Rational

data ArcF a = Arc

  { start :: a,
    stop :: a
  }

  deriving (Eq, Ord, Functor, Show, Generic)
  
type Arc = ArcF Time

instance Applicative ArcF where
  pure t = Arc t t
  (<*>) (Arc sf ef) (Arc sx ex) = Arc (sf sx) (ef ex)

instance (NFData a) => NFData (ArcF a)

instance (Num a) => Num (ArcF a) where
  negate = fmap negate
  (+) = liftA2 (+)
  (*) = liftA2 (*)
  fromInteger = pure . fromInteger
  abs = fmap abs
  signum = fmap signum

instance (Fractional a) => Fractional (ArcF a) where
  recip = fmap recip
  fromRational = pure . fromRational

It defines Time as a Rational and defines ArcF a as a structure that expresses a range of time between start and stop as a. Arc is then an ArcF of type Time. Again we then see behavioral and structural type interfaces. Arc is the fundamental abstraction of time in tidal, representing a fragment of a full cycle, hence the name TidalCycles. Essentially a cycle is subdivided into a number of Arcs equal to the number of sounds (samples or notes) in your pattern. So for example "b5 d5 g5 c4 e4" would create a cycle split into 5 equally sized Arcs each playing one note. Effectively creating a measure (loop) with 5 beats (in odd time).


Well, no dependencies. We have reached the "true" core of the onion.

tidal-core\src\Sound\Tidal\Scales.hs

A file defining lists of numeric values, for note intervals representing scales. Allowing you to lookup scale names and to use scale degrees to express notes, as an abstraction inside an harmonic context.


(Sound.Tidal.Core (slowcat))
(Sound.Tidal.Pattern (Pattern, (<*)))
(Sound.Tidal.Utils ((!!!)))

tidal-core\src\Sound\Tidal\Simple.hs

I will leave here the explanation of the author: "Things for making Tidal extra-simple to use, originally made for 8 year olds"


(Sound.Tidal.Control (chop, hurry))
(Sound.Tidal.Core ((#), (<~), (|*)))
(Sound.Tidal.Params (crush, gain, pan, s, speed))
(Sound.Tidal.ParseBP (parseBP_E))
(Sound.Tidal.Pattern (ControlPattern, rev, silence))

tidal-core\src\Sound\Tidal\UI.hs

This is a collection and wrapper for all the tidal commands in a streamlined interface exposed to the user.


Sound.Tidal.Bjorklund (bjorklund)
(Sound.Tidal.Core)
(Sound.Tidal.Params as P)
(Sound.Tidal.Pattern)
(Sound.Tidal.Utils)

It has one unique new dependency.

tidal-core\src\Sound\Tidal\Stepwise.hs

"Functions that deal with stepwise manipulation of pattern". It is pure as it deals with patterns.


(Sound.Tidal.Core (stack, timecat, zoompat))
(Sound.Tidal.Pattern)
(Sound.Tidal.Utils (enumerate, nubOrd, pairs))

tidal-core\src\Sound\Tidal\Bjorklund.hs

Euclidian patterns taken from the hmt library. It deals with Calculations around the type

type STEP a = ((Int, Int), ([[a]], [[a]]))

Which according to this definition is a tuple that maps a tuple of Int (the step) to a tuple of a two-dimensional List of a.

All of this seems to lead to one exposed function
bjorklund :: (Int, Int) -> [Bool]

So given a Int tuple returns a list of Bool.


No Dependencies

src\Sound\Tidal\Version.hs

This one just defines some constants about the version


Paths_tidal

This one is interesting. It is a module without definition in the source code. Instead it is auto-generated by the project manager cabal probably for self-reflection about the internal pathing.
autogen-modules:     Paths_tidal

tidal-link\src\hs\Sound\Tidal\Clock.hs

This one is odd. Many modules from the Stream directory depend on this, however it is located inside the source code for the Ableton Link extension. Clock has a lot of pure but also impure functions dealing with IO. Like tickingAction. Intuitively this would imply that the modules depending on it would also turn impure.

But it is not that simple.

Modules just share definitions, so based on the dependencies we can not determine impurity of an entire module. It is when impure functions are called inside of other function that they turn impure.


Sound.Tidal.Link as Link

tidal-link\src\hs\Sound\Tidal\Link.hsc

This is a module establishing an interface to Ableton with the custom type AbletonLink.
By now this should already imply an IO . Which on closer look it does. Actually this is fascinating, being located inside a .hsc file this suggests a foreign interface to C. And most functions are wrappers to function pointers into C code, and as such are all dealt with inside the type IO.


No external dependencies

src\Sound\Tidal\Stream\Listen.hs

openListener :: Config -> IO (Maybe O.Udp)
ctrlResponder :: Config -> Stream -> IO ()

I am starting to notice a pattern. It seems the higher we walk into the impure IO world the less definitions we get. This one is to setup a OSC Listener.


Sound.Tidal.ID
Sound.Tidal.Stream.UI
(Sound.Tidal.Pattern)
(Sound.Tidal.Stream.Config)
(Sound.Tidal.Stream.Types)

src\Sound\Tidal\Stream\UI.hs

This one defines a lot of IO operations that wrap some internal types for a streamlined user experience.


(Sound.Tidal.Clock as Clock)
(Sound.Tidal.ID)
(Sound.Tidal.Pattern)
(Sound.Tidal.Stream.Config)
(Sound.Tidal.Stream.Process)
(Sound.Tidal.Stream.Types)

src\Sound\Tidal\ID.hs

newtype ID = ID {fromID :: String} deriving (Eq, Show, Ord, Read)

noOv :: String -> a
noOv meth = error $ meth ++ ": not supported for ids"

instance Num ID where
  fromInteger = ID . show
  (+) = noOv "+"
  (*) = noOv "*"
  abs = noOv "abs"
  signum = noOv "signum"
  (-) = noOv "-"
  
instance IsString ID where
  fromString = ID

String identifiers for patterns with a funny implementation for Num which returns in an error, should users attempt to use IDs like patterns. It is a pure module.


No dependencies

src\Sound\Tidal\Stream\Process.hs

This is the consumer of Event ValueMap, which inside a tick is processes and converted to OSC signals for the stream. It is mostly pure, dealing with Calculations involving OSC and the domain types. However here and there we have some IO operations.


Sound.Tidal.Clock as Clock
Sound.Tidal.Core (stack, (#))
Sound.Tidal.ID (ID (fromID))
Sound.Tidal.Link as Link
Sound.Tidal.Params (pS)
Sound.Tidal.Pattern
Sound.Tidal.Pattern.Types (patternTimeID)
Sound.Tidal.Show ()
Sound.Tidal.Stream.Target (send)
Sound.Tidal.Stream.Types
Sound.Tidal.Utils ((!!!))

tidal-core\src\Sound\Tidal\Show.hs

This module is purely dedicated to make Pattern behave like a Show type, converting patterns to Strings so they can be output to stdout


(Sound.Tidal.Pattern)

As a consequence it only depends on Pattern.

tidal-core\src\Sound\Tidal\Utils.hs

These are some utility functions to deal with Haskell types in the context of tidal like Maybe and List. As a consequence they are pure definitions. With one exception, building an Error.


Without any dependencies.

tidal-core\src\Sound\Tidal\Pattern\Types.hs

This exists... It defines a single string pattern for pattern IDs. Odd choice to put this in a separate module. But maybe this is good for extensibility.


No dependencies at all (not even external)

tidal-core\src\Sound\Tidal\Chords.hs

This module defines chord types and enumerates them in a list. A chord is a suffix you can give to a note in a pattern and it is mapped to a collection of intervals applied to the root note.


(Sound.Tidal.Pattern)

Applying the Theory

After this long breakdown of all modules, we have sufficient material to which we can apply theoretical ideas. So let us review the most important points:

Eric Normand discusses the different components of what make the paradigm tick, and then discusses how maximizing (pure) one and minimizing (impure) the other is very important. He stresses the importance of separating pure from impure functions to end up in an onion architecture structured from the most pure core to an impure shell, moving up in layers covering different domains.

Mark Seemann on the other hand is more focused on the "pits of success" that functional programming languages enforce by design by clearly separating between impure and pure functions, reiterating the onion architecture as a port and adapters architecture. He then talks about domain-driven design and how the clear separation of data and functions to operate on the data allows for clean domain design.

If we combine the two we end up with a methodology with three steps.

  1. Draw the line between pure and impure code, identifying ports and adapters
  2. Identify Stratification of domains, from very specific to unspecific: layers of meaning -> Abstraction, how are these Domains structured in Data and Functions, and how is the Module organized as a Service?
  3. Build an onion from most generic pure core, to impure specific shell.

1. Drawing the Line between Pure and Impure Code

As already observed, separating code in pure and impure is fairly simple in Haskell. We just look for the IO Type in the signature. More challenging is to decide on the unit of analysis. But since Seemann equates Modules to services and class replacements in functional programming, we will take the module as a whole and divide the number of impure function definitions by the amount of pure function definitions to get an index of impurity. We will then rank them from most impure to most pure. When we have the final list we will also divide the final amount impure modules by the total number of modules to give the entire project an impurity rate.

If you wonder how I counted, I used the built-in search function of VS Code with the following regex expression ^\w+\s?:: . I identified impure definition with ^\w+\s?::(?=.*IO) and data definitions with ^\w+\s?::(?!.*->)

Sound.Tidal.Safe.Context (src\Sound\Tidal\Safe\Context.hs) - 26/26 - 100% impure
Sound.Tidal.Stream.Main (src\Sound\Tidal\Stream\Main.hs) - 3/3 - 100% impure
Sound.Tidal.Stream.Listen (src\Sound\Tidal\Stream\Listen.hs) - 2/2 - 100% impure
Sound.Tidal.Link (tidal-link\src\hs\Sound\Tidal\Link.hsc) - 6/6 - 100% impure
Sound.Tidal.Stream.UI (src\Sound\Tidal\Stream\UI.hs) - 33/33 - 100% impure
Sound.Tidal.Clock (tidal-link\src\hs\Sound\Tidal\Clock.hs) - 23/34 - 67% impure
Sound.Tidal.Version (src\Sound\Tidal\Version.hs) - 2/3 - 66% impure
main (Main.hs) - 4/8 - 50% impure

Sound.Tidal.Stream.Target (src\Sound\Tidal\Stream\Target.hs) - 7/17 - 41% impure
Sound.Tidal.Stream.Process (src\Sound\Tidal\Stream\Process.hs) - 5/12 - 30% impure
Sound.Tidal.Stream.Config (src\Sound\Tidal\Stream\Config.hs) - 1/4 - 25% impure
Sound.Tidal.Transition (src\Sound\Tidal\Transition.hs) - 1/18 - 15% impure
Sound.Tidal.Utils (tidal-core\src\Sound\Tidal\Utils.hs) - 1/22 - 8% impure


Sound.Tidal.Stream.Types (src\Sound\Tidal\Stream\Types.hs) - 0/0 - (data)
Sound.Tidal.Control (tidal-core\src\Sound\Tidal\Control.hs) - 0/40 - 0% impure
Sound.Tidal.Core (tidal-core\src\Sound\Tidal\Core.hs) - 0/210 - 0% impure (50% data)
Sound.Tidal.Params (tidal-core\src\Sound\Tidal\Params.hs) - 0/1302 - 0% impure
Sound.Tidal.ParseBP (tidal-core\src\Sound\Tidal\ParseBP.hs) - 0/69 - 0% impure
Sound.Tidal.Pattern (tidal-core\src\Sound\Tidal\Pattern.hs) - 0/114 - 0% impure
Sound.Tidal.Time (tidal-core\src\Sound\Tidal\Time.hs) - 0/18 - 0% impure
Sound.Tidal.Scales (tidal-core\src\Sound\Tidal\Scales.hs) - 0/84 - 0% impure (90% data)
Sound.Tidal.Simple (tidal-core\src\Sound\Tidal\Simple.hs) - 0/12 - 0% impure
Sound.Tidal.UI (tidal-core\src\Sound\Tidal\UI.hs) - 0/227 - 0% impure
Sound.Tidal.Bjorklund (tidal-core\src\Sound\Tidal\Bjorklund.hs) - 0/3 - 0% impure
Sound.Tidal.ID (src\Sound\Tidal\ID.hs) - 0/1 - 0% impure (mostly data)
Sound.Tidal.Pattern.Types (tidal-core\src\Sound\Tidal\Pattern\Types.hs) - 0/1 - 0% impure (data)
Sound.Tidal.Chords (tidal-core\src\Sound\Tidal\Chords.hs) - 0/58 - 0% impure
Sound.Tidal.Show (tidal-core\src\Sound\Tidal\Show.hs) - 0/19 - 0% impure
Sound.Tidal.Stepwise (tidal-core\src\Sound\Tidal\Stepwise.hs) - 0/26 - 0% impure

Across all modules, we have a 13/16 split which is 45% of impure modules to pure modules. If we are a bit more lenient and draw the line at 50% impurity inside the module as the threshold, we have 25%. This still seems to be a lot for an architecture that tries to minimize impurity. However if we count the full amount of impure functions and divide it by the amount of pure functions the full ratio is 114/2372 which is 4.8% impure definition to pure definitions.

What this step also reveals is the relatively small portion of definitions in impure modules, with a maximum of 33 entries in Stream.UI in respect to the amount of definitions in pure modules, with a maximum of 1302 pure definitions in Params.
What makes this analysis almost redundant, and therefore seems to be a good practice is to separate the pure modules in a core folder, and placing the impure modules in their own source folder. Also worth noting is how both *UI modules (that expose simplified commands to the user) exist in their respective subfolders, but are complete opposites with 100% impurity for Stream.UI and 100% purity for UI.

Before moving on, I want to highlight modules that defied my expectation.
main is only 50% impure (I expected 100% impure) because some block formatting and safe list functionality was declared in that module. For a clearer separation of concerns, I would have put them in their own domain-specific module. Utils being in the pure core directory had an impure declaration for writing Errors to the stderror, which also seemed quite unnecessary. Here I would suggest similarly to how both directories got their own UI that also both directory get their own Utils. Finally a module that surprised me was Process which other than it's name suggests is only 1/3 impure. Reason for this is that the module contains many internal functions that work with OSC and ControlPattern types. Again I think these would be better suited for their own OSC translation module.

Until now our analysis method was focused on syntactical features, as we used regex to conduct most of the research. Now we need to look past the syntax and focus on the semantics, or the meaning behind the syntax.

2. Stratification and Organization of Domains

In this step we want to identify what layers exist in the application by showing what domains exist and how they are modeled. Also we want to show which concepts of lower level domains are necessary to express concepts of higher level domains.

Domain is a contextual space of related concepts and the same word in two different domains refers to different concepts. We will use Seeman's definition of data and services to define what each module represents, drawing boundaries around related modules, speaking the same language.

Since the last step of our analysis is the final construction of the onion architecture, which begins from a pure core, we will start our analysis there. To identify the pure core we select all pure modules that have very little to no dependencies.

Additional context about the role of the module is extracted from comments by the authors.

Tidal.Time

Dependencies: ()
Data: Time , Arc, Arcf

This module defines what Time is and how an Arc is structured. It allows to map values to the arc, convert between time ranges, and helps to structure a Cycle. While not mentioned explicitly Cycle is the core concept of tidalcycles and this module is responsible to express it.

Dependencies: ()
Data: AbletonLinkImpl, AbletonSessionImpl, AbletonLink, AbletonState

This module is an extension to interface with Ableton. Also it is the only module in hsc format, meaning it is compiled to a C interface.

Tidal.Pattern.Types

Dependencies: ()
Data:

This module just defines the format string for a pattern id.

Tidal.ID

Dependencies: ()
Data:

This module defines String identifiers for patterns with a funny implementation for Num which returns in an error, should users try to use IDs like patterns.

Tidal.Bjorklund

Dependencies: ()
Data: STEP

This module uses the internal Type Step to express Euclidian patterns.
These can be used by the user to express polyrhythms.

Tidal.Utils

Dependencies: ()
Data:

This module is like a swiss army knife helping to convert between Haskell types for specific reusable purposes.

Tidal.Version

Dependencies: ()
Data: tidal-version

This module's only job is to provide information about the software version.

Tidal.Pattern

Dependencies: (Tidal.Time)
Data: State, Pattern, Context, EventF, Value, Note, ControlPattern

This module is about mapping values to Arc of time building Events and Patterns. I am surprised to see Note as a definition here, since this is a concept from the music domain.
Tidal's pattern abstraction has been used in the past to control non-musical systems.
I feel this showcases the power of having clear yet abstract domains, and very abstract core definitions and I am therefore surprised to find Note defined here. Note is necessary, for Value depends on it. There is no other service which puts pattern explicitly inside the domain of music.

Tidal.Show

Dependencies: (Tidal.Pattern)
Data: Render

This module is an extension of the Pattern type defining properties so it can be represented as string. It also defines a number driven String type Render which seems to be used to draw lines and other characters to have visual representations in the console of structure and time.

Tidal.Core

Dependencies: (Tidal.Pattern)
Data:

This module builds on Pattern to form a collection of operations and concepts to construct patterns. It is not apparent why some functions are listed here and not UI, and the other way around. And other than what the name implies, Core is not the core of the application.

Tidal.Chords

Dependencies: (Tidal.Pattern)
Data: Modifier

This module is responsible to express chord names as interval sequences of notes and to list them in a lookup table. It also defines a type Modifier which is used to express Inversion, dropping a note or Opening a chord. This is amazing! An undocumented feature which expands the harmony domain even further.

Tidal.Clock

Dependencies: (Tidal.Link)
Data: Time, Clock, ClockState, ClockRef, ClockConfig, TickAction, ClockAction

This module defines a lot of time related types based on Clock and also redefines Time. This seems to be the impure counterpart to Time.

Tidal.Stream.Config

Dependencies: (Tidal.Clock)
Data: Config

This module defines the Configuration format of the Application.

main (Main.hs) - 4/8 - 50% impure

Dependencies: (Tidal.Safe.Context)
Data:

Main as the entrypoint is not aware of concepts such as patterns or music. It's only responsibility is to create the main application process, connecting to external systems and spawning the main stream.

Tidal.Params

Dependencies: (Tidal.Utils, Tidal.Core, Tidal.Pattern)
Data:

This module has no explicit data definition, however this module shows how pure functions can be very close to data. For example the function drum defines a mapping from a string name to a number, allowing to express drum patterns with names. The functions in this module all produce ControlPattern from Pattern, String and other simpler types.
It is essentially the module that translates syntax to finished structures digestible by SuperCollider. It is less about music, but about defining the parameters and building blocks of music in terms of values, sample names, and other properties.
This one is more about an intersection of Control, Music and Pattern in it's domain language. It's purpose is to provide a wide collection of definitions to express Commands for SuperCollider.

Tidal.Stepwise

Dependencies: (Tidal.Core,Tidal.Pattern, Tidal.Utils)
Data:

This module again has no data definition, but implies the concept of stepwise pattern manipulation.

Tidal.Stream.Target

Dependencies: (Tidal.Pattern, Tidal.Stream.Config, Tidal.Stream.Types )
Data:

This module does not define data, as that is handled in Stream.Types. It is responsible to creating and sending to OSC Targets.

Tidal.Scales

Dependencies: (Tidal.Core, Tidal.Pattern, Tidal.Utils)
Data:

This module screams musical concepts, mainly harmony. Using Pattern and other simpler types it expresses note intervals characteristic of certain musical scales.

Tidal.Stream.Types

Dependencies: (Tidal.Show, Tidal.Pattern, Tidal.Stream.Config)
Data: Stream, Cx, StampStyle, Schedule, Target, Args, OSC, PlayState, PatId, PlayMap

This module is a pure data type definition, which breaks the pattern we have seen so far, aswell as the pattern proposed by Seemann, in which classes are replaced by Modules defining their own data and services. Seems to be very inconsistent with the core modules.
The types defined here refer to concepts that gravitate towards Processes, Signals/Communication, and State/Timing.

Tidal.UI

Dependencies: (Tidal.Bjorklund, Tidal.Core, Tidal.Params , Tidal.Pattern, Tidal.Utils)
Data:

This module is interesting, as it resembles Params, however it deals mostly with Pattern exposing a rich collection of pattern operations and just a few for ControlPattern. It's purpose is as library for the user to create and manipulate patterns.

Tidal.Control

Dependencies: (Tidal.Params, Tidal.Pattern.Types, Tidal.Core, Tidal.Pattern, Tidal.UI)
Data:

This module deals mostly with ControlPattern. The dependency to UI seems odd, as it depends on two functions, confirming my suspicion I was having about unclear responsibility between Core and UI. Other than what the name suggests, this Module does not define ControlPattern, which is defined inside Pattern itself, even though that module (Pattern) uses ControlPattern for 8 extraction functions. Seems to be a questionable choice once again showing some lack of a stringent separation of concerns.

Tidal.Simple

Dependencies: (Tidal.Control, Tidal.Core, Tidal.Params, Tidal.ParseBP, Tidal.Pattern)
Data:

This module is to Params what UI is to Pattern. It builds additional syntactic sugar for some Control commands to simplify the User experience even further.

Tidal.ParseBP

Dependencies: (Tidal.Chords, Tidal.Core, Tidal.Pattern, Tidal.UI, Tidal.Utils)
Data: TidalParseError , Sign, TPat

This module's main responsibility is to build the mini-notation a string in a specific format that allows to succinctly express patterns of notes and chords as strings. The type TPat represents the AST representation of patterns, which is the internal "notation" representation.

Tidal.Stream.Listen

Dependencies: (Tidal.ID, Tidal.Stream.UI, Tidal.Pattern, Tidal.Stream.Config, Tidal.Stream.Types)
Data:

This module opens a listener port for OSC messages.

Tidal.Stream.UI

Dependencies: (Tidal.Clock, Tidal.ID, Tidal.Pattern, Tidal.Stream.Config, Tidal.Stream.Process, Tidal.Stream.Types)
Data:

This module defines commands for the user to "effectfully" control the state of playback inside the tidal system like streamMute. I think these are once more wrapped inside BootTidal.hs into commands like hush.

Tidal.Stream.Main

Dependencies: (Tidal.Clock, Tidal.Stream.Listen, Tidal.Stream.Process, Tidal.Stream.Target, Tidal.Stream.Types, Tidal.Version, Tidal.Stream.Config)
Data:

This module represents a more domain-specific main or entrypoint that defines what tidal is as a process or more specifically, stream.

Tidal.Transition

Dependencies: (Tidal.Clock, Tidal.Control, Tidal.Core, Tidal.ID, Tidal.Params, Tidal.Pattern, Tidal.Stream.Config, Tidal.Stream.Types, Tidal.UI, Tidal.Utils)
Data: TransitionMapper

This module implements one central function transition, and it feels like it depends on every other module pointing to a potential design problem. At it's core transition is about moving smoothly from one playing pattern to the next.

Tidal.Stream.Process

Dependencies: (Tidal.Clock, Tidal.Core, Tidal.ID, Tidal.Link, Tidal.Params, Tidal.Pattern, Tidal.Pattern.Types, Tidal.Show, Tidal.Stream.Target, Tidal.Stream.Types, Tidal.Utils )
Data: ProcessedEvent

This module turns Patterns into OSC Streams, by processing events.

Tidal.Safe.Context

Dependencies: (Tidal.Stream.Main, Tidal.Stream.Target, Tidal.Stream.Config, Tidal.Stream.Types, Tidal.Control, Tidal.Core, Tidal.Params, Tidal.ParseBP, Tidal.Pattern, Tidal.Scales, Tidal.Stepwise, Tidal.Simple, Tidal.UI, Tidal.Version)
Data:

This module simply wraps all Stream and Core modules together in one Context layer as one central layer of indirection for external systems that depend on it.


During the listing I was doing mental notes and then had a final look comparing and relating modules with each other. I came to identify the following Domains:

Application - Which aknowledges TidalCycles as versionable software artefact with a name.
Tidal.Stream.Config, Tidal.Version, main, Tidal.Stream.Main, Tidal.Stream.Context

Process - Which creates and manages process level operations integrating with other systems
Tidal.Stream.Types, Tidal.Stream.Main, Tidal.Stream.Listen, Tidal.Stream.Target, Tidal.Stream.Process, Tidal.Link, Tidal.Clock

Time - Abstractions that define how time is represented and structured, which brings us to...
Tidal.Time, Tidal.Clock

Pattern - Patterns are events and values tied to time. In this abstract form they do not
explicitly imply music, but any form that can be expressed as events over time.
Tidal.Pattern, Tidal.Stepwise, Tidal.UI, Tidal.Core, Tidal.Bjorklund, Tidal.ID

Music - Considering that this is TidalCycles main use case, I was surprised how little musical concepts have been expressed explicitly.
Tidal.Scales , Tidal.Chords

Sound Control - I distinguish this from Music, the same way as I would distinguish a Sound Designer from a Music Composer. A sound designer deals with the sound as a raw signal, waveform, cutting and pasting, editing it like a picture. While a music composers deals with abstractions such as timbre, notes (pitches), harmony, musical form.
Tidal.Transition, Tidal.Params, Tidal.Control

Language - TidalCycles presents a user-friendly language that is embedded in Haskell and allows non-haskell programmers to express sound related ideas with it.
Tidal.UI, Tidal.Core, Tidal.Simple

Text - While this could be put under Language, I wanted to clearly differentiate, as this domain deals with how text is processed as input and translated to the internal Language, and how the results in turn are rendered as text to be communicated to the user.
Tidal.BPParse , Tidal.Show

To confirm the completeness of the enumerated domains, let me try to rephrase the application in terms of the here listed domains:

TidalCycles is both an Application managing processes and connecting to external systems and a Text-based Language through which a user can produce music and control sounds by creating and transforming patterns over time.

3. The final Onion Architecture

The order and position of the modules inside the onion will be a combination of the amount of dependencies, their domain relatedness, their purity, etc... For example main.hs will be on the outer boundary eventhough it has only one dependency. And config will be close to the outer shell even though it is pure, because it is part of the application domain. Also config does not represent a fundamental module that defines concepts such as time and patterns, which tidalCycles is designed for. It is a descriptive meta-layer.

I started enumerating all modules in order of my list, connecting them by their dependencies, and ended up with this....

chrome_KmsOYpoQpW.png

Now I know why it is called an onion and not a network

OnionTidal@2x.png

What can we learn from this representation?

First of all it summarizes all of our existing observations. We separated pure from impure modules. When applicable we put the low to no dependency pure modules in the core. With the exception of Stream.Types, Stream.Config, Version and ID which reside the outermost pure layer, as they contain application level information not suited for the pure domain core. Also we were able to both capture modules belonging to one domain and layer them on top of their main dependencies. The blueish music domain for example with Chords and Scales or the ParseBP and Show text layer which is then engulfed by the User and Control layer. The Transition layer is fully impure, but belongs to the control domain. I had to compromise with this representation. Which shows that representation is in itself just an abstraction (that has to prioritize some properties over others).

Also Time and Clock belong into the same domain, however combining the impure Clock module that depends on a outer layer interface (Link) with the pure time domain that defines the entire heart of tidalcycles seemed odd.

I understand that Context is a layer of indirection by the authors to have one central source of truth to extend in case of new features, but this obscures the explicit dependencies needed for main, which leads to this odd middle layer between Stream modules and main.
Also Context, being a container of modules, is ambiguous about purity.

Take-Away

If we think of the core as the lowest innermost layer and the shell of the highest outermost interaction layer. The onion structure seems to invert the conceptual order of low to high level. Having the higher level concepts (most abstraction) reside in the lower-level core. While the lower-level process (least abstraction) and system interfacing sits on the highest level. Also for object-oriented programmers it helps to think of modules in functional programming as classes. Where instead of combining everything inside one big block, instead every data/function declaration and definition lives as a self-contained unit, further promoting reusability and flexibility. Every module then can be associate to it's own data types and an entire library that builds around it. Which makes it also easier to cherry pick exactly what you need from each module (example for testing), instead of having to carry around the entire class as context. From a designers perspective I love how the onion architecture naturally leads to the stratification of domains, in which concepts depend on more basic concepts building up your domain language organically.