OK, if you like working that way then more power to you! I’ll stick with libraries that are open to extension.
osa1:
OOP is not that bad, actually
The Expression Problem (1998):
-
favouring (functional) code over data (constructors) makes it more difficult to add extra constructors later to existing functions;
-
favouring data (objects) over (method) code makes it more difficult to add extra methods later to existing objects.
It’s something to remember when reading articles like OOP is not that bad, actually…
Is this the expression problem?
Perhaps it’s somewhat related. According to Wikipedia, in the expression
The goal is to define a data abstraction that is extensible both in its representations and its behaviors
I’m talking about the need to be extensible in behaviours. When @vshabanov says that you can define data Logger = Stdout | File Handle
and then add further constructors if you like, that is about being extensible in representation.
For me the former is satisfactory and the latter unsatisfactory. I’m not really trying to obtain both at once.
In effects systems the representations are the effects and their operations and the behaviors are the handlers. This is more obvious if you implement them using the Free
monad data type, for example the paper Data Types à la Carte essentially presents a solution to the expression problem and it ends with a Free
monad based implementation of algebraic effects.
But I think even Bluefin can be understood to solve the expression problem in this way. Combining multiple effects into one Eff
data type is representation extensibility and handling effects in multiple different ways is behavior extensibility.
I’d reiterate that the premise of working “without being in control of the interface definition” is wrong and leads to code rot.
We shouldn’t base our designs on the approach that leads to code rot.
Write something basic first and give it a go. YAGNI. Once you have several real common use cases, you can generalise. And there are many ways to do that, including changing the interface.
It’s convenient to stick with libraries that are open to extension, but how do you build them? With iterations, which may involve forks and PRs.
If adding a constructor surprises you, take a look at fast-logger
. It has a generic LogCallback constructor that wasn’t there from the beginning. Someone made a PR.
I would like to offer another alternative, which consists in using a separate data structure to do all the wiring.
The components, are defined as record of functions:
data Logger = Logger
{ log :: Text -> Severity -> IO ()
}
data Database = Database
{ executeQuery :: Text -> IO ()
}
There are constructor functions to build them (those functions can be effectful if needed):
-- | A production database using a Logger
newDatabase :: Logger -> Database
newDatabase logger = Database {..} where
executeQuery :: Text -> IO ()
executeQuery q = do
log logger ("executing query " <> q) Info
print "do it
And a registry
to wire them in different scenarios, in an incremental way (no need to change existing configurations):
prodRegistry =
fun newDatabase
<: fun newLogger
prodDatabase = make @Database prodRegistry
-- Don't log anything for those tests
testRegistry1 =
fun noLogger
<: prodRegistry
testDatabase1 = make @Database testRegistry1
-- Only log some messages for those tests
testRegistry2 =
tweak @Logger (limitSeverity Info) prodRegistry
testDatabase2 = make @Database testRegistry2
-- Only log some messages for those tests
-- Specify the severity as a separate value
testRegistry3 =
fun newLimitedLogger
<: val Info
<: prodRegistry
testDatabase3 = make @Database testRegistry3
You can even declare that you want to limit the logging severity only to some components of your application graph and not others.
You can find a running example here if you want to play with it:
In this approach we start with interfaces, there’s no “concrete class” as @osa1 would say, but I don’t see how this is a problem. Eventually we get to same goal: clients like Database
don’t need to change their code if there’s a new implementation.
I think that the very important point is that the _wiring_code can be modified incrementally. In @osa1’s example MyApp.testingSetup()
redefines the whole wiring that MyApp()
does.
There is a similar issue with using effect libraries to describe application components. At some stage you end up with a stack of:
runEffect1
runEffect2
runEffect3
runEffect4
runEffect5
and if you just want to modify the interpreter for effect3 you have to rewrite the whole stack. And if you want to modify how that effect is interpreted, just when it is used in the context of effect4 but not in the context of effect5 (say logging differently for the database or HTTP calls), then you can’t do it.
BTW I’m not saying that registry
is a perfect solution, I’m just trying to highlight some good (IMO) aspects of it!
registry
is great. I’ve seen it mentioned before and liked the idea.
It’s cool that it’s completely orthogonal:
-- one can write
a :: IO A
b :: IO B
c :: A -> B -> C
-- and use them directly
main = c <$> a <*> b
-- or put them in the registry
I’m not sure what to do with a typical use case where functions have both static “component” and dynamic parameters and a dynamic call graph:
foo :: A -> C -> String -> IO ()
foo a c s = ... x <- bar a; baz c (if x then 1 else qux a s)
bar :: A -> IO Bool
baz :: C -> Int -> String
qux :: A -> String -> Int
But if the system can be decomposed into a static component graph, then registry
can be nice.
I was too surprised to see MyApp.testingSetup()
followed by “MyApp methods didn’t have to change at all.”
Hi Vladimir,
I would do the following:
- Request-scoped dependency
A dependency might only be used depending on requests. In that case I add it and use it only when necessary:
data TransactionEngine = TransactionEngine {
processTransaction :: Transaction -> IO Report
}
newTransactionEngine :: Logger -> Database -> Emailer -> TransactionEngine
newTransactionEngine logger database emailer = TransactionEngine {..} where
processTransaction t = do
-- process the transaction
report <- doSomething
when sendReport transaction do
sendEmail emailer report
saveReport database report
- Configuration-based dependency
Sometimes the exact set of components to use are known at runtime, based on configuration parameters. In that case I create 2 registries:
appRegistry = if isSandbox config then sandboxRegistry else prodRegistry
application = make @Application appRegistry