-
Notifications
You must be signed in to change notification settings - Fork 87
Coding Standards
This file contains agreed-upon coding standards and best practices, as well as proposals for changes or new standards. Also, the concept and topics are shamelessly stolen from the Adrestia Wiki (thanks guys!).
Proposals are prefixed with [PROPOSAL]
until discussed and voted on by the
Hydra team. To be accepted, a practice should be voted with majority + 1, with
neutral votes counting as positive votes.
Each proposal should start with a section justifying the standard with rational arguments. When it makes sense, we should also provide good and bad exmaples to make the point clearer.
Here are seven rules for great git commit messages:
- Separate subject from body with a blank line
- Limit the subject line to 50 characters (soft limit)
- Capitalize the subject line
- Do not end the subject line with a period
- Use the imperative mood in the subject line and suffix with ticket number if applicable
- Wrap the body at 72 characters (hard limit)
- Use the body to explain what and why vs. how
Why
Git commit messages are our only source of why something was changed the way it was changed. So we better make the readable, concise and detailed (when required).
In general, we will use a code formatting tool and enforce it's usage by CI or commit hooks. Refer to the appropriate configuration file in our repository. Any change to which tool and configuration is used shall be discussed and voted upon in the corresponding pull request. Things which are not covered by our tool of choice are also mentioned and voted on in here.
Why
- Focus on content and logic when writing and reviewing code
- Avoid discussions (and bikeshedding) code format (e.g. indentation) on pull requests
ℹ️ Should be enforced by code formatting tool (e.g. Fourmolu).
A source code file should not contain multiple consecutive blank lines.
Use only a single blank line between the following top-level definitions:
- function definitions
- data type definitions
- class definitions
- instance definitions
Why
- Consistency with other Haskell code.
- Excessive vertical space increases the amount of unnecessary scrolling required to read a module.
See Examples
-- BAD
newtype Foo = Foo Integer
deriving (Eq, Show)
newtype Bar = Bar Integer
deriving (Eq, Show)
-- GOOD
newtype Foo = Foo Integer
deriving (Eq, Show)
newtype Bar = Bar Integer
deriving (Eq, Show)
-- BAD
instance FromCBOR Block where
fromCBOR = Block <$> decodeBlock
newtype BlockHeader = BlockHeader
{ getBlockHeader :: Primitive.BlockHeader
} deriving Eq
-- GOOD
instance FromCBOR Block where
fromCBOR = Block <$> decodeBlock
newtype BlockHeader = BlockHeader
{ getBlockHeader :: Primitive.BlockHeader
} deriving Eq
- We do use short and concise names for fields in record data types.
- We use
DuplicateRecordFields
when needed to avoid conflicts. - We use type annotations, getField or generic-lens to disambiguate record field access.
Why
- Use the most suitable name for record field without restrictions.
- This allows to derive instances for pretty-printing, serialization etc. with minimal amount of boiler-plate.
- Avoid redundancy and errors because of it
See Examples
-- GOOD
data Foo = Foo
{ name :: Integer
, value :: Value
}
-- BAD
data Foo = Foo
{ fooName :: Integer
, fooValue :: Value
}
-- BAD
data FooBarBaz = Foo
{ _fbbName :: Integer
, _fbbValue :: Value
}
Do not pass Bool
as arguments to functions if it can be avoided. Alternatives are (also see examples):
- Make it two functions
- Create a
newtype
aroundBool
(We do want to avoid that as well though) - Create a dedicated
data
type
Why
- Side-steps the type system and misses the opportunity to catch errors due to mixing multiple
Bool
arguments- Helps in documenting what's going on (we can still add documentation of course)
See Examples
-- WORST
client :: Bool -- ^ Adding documentation is not enough
-> IO ()
client pipelined =
-- Naming ^ the argument is not enough
pure ()
-- BAD
type IsPipelined = Bool
client :: IsPipelined -> IO ()
client pipelined =
-- Naming ^ the argument is not enough
pure ()
-- BAD
newtype IsPipelined = IsPipelined Bool
client :: IsPipelined -> IO ()
-- GOOD
clientSimple :: IO ()
clientPipelined :: IO ()
-- GOOD
data IsPipelined = Simple | Pipelined
client :: IsPipelined -> IO ()
For user-defined kinds, we want to use ticked constructors in type signatures.
Why
- Clearly identifies that a data-type is being promoted as kind.
See Examples
data FireForget msg where
StIdle :: FireForget msg
StDone :: FireForget msg
-- BAD
fireForgetClientPeer :: Peer (FireForget msg) AsClient StIdle m a
fireForgetClientPeer = ...
-- GOOD
fireForgetClientPeer :: Peer (FireForget msg) 'AsClient 'StIdle m a
fireForgetClientPeer = ...
Use camel-case for all identifiers, whether functions or types, including acronyms.
Why
- Not-quite-camel-case identifiers introduce irregularity in names which might hamper interoperability (eg. generating snake-case identifiers from camel-case)
See Examples
-- BAD
data UTXO = ...
data UTxO = ...
-- GOOD
data Utxo = ...
Distinguish between errors which are common and errors which are exceptional, e.g. validating something fails "as likely" as it will not, whereas sending a message over the network is expected to work usually.
Here are some condensed rules we try to follow:
- Try to avoid errors in the first place "by design", e.g. using smaller type like
Natural
orNonEmpty
lists (Source) - Avoid
error
and it's derivates at all cost! Add a huge comment if you still need to use it.
All (pure) functions must be total so there's no use for error
or panic
except as a placeholder or Test Double to remind one there's more test to write. The only reason why one would use panic
in production code is to satisfy the completeness checker of the compiler even though we know some case is impossible. In this case, it's then better to use Data.Void.absurd
.
bar :: IO a
bar = threadDelay 1_000_000 >> bar
foo :: Int -> IO Int
foo x = pure (2 * x)
run :: Int -> IO ()
run x = do
result <- race bar (foo x)
case result of
Left void_ -> absurd void_ -- there is no way `bar` can complete before `foo`
Right a -> print a
- Use
Either e a
for expected errors (or evenMaybe
if there is only one error case) - Use
m (Either e a)
for the same reason in monadic code - We want to avoid
ExceptT e IO
and thus also inMonadIO m
situations:
ExceptT
is bad because:
- It's inefficient
- It makes code more complicated
- It does add any form of safety as the undelrying
IO
s can still throw exceptions anyhow
Also, make sure e
is a proper error type with relevant information, possibly even an instance Exception MyError
. And have ToJSON
and FromJSON
instances to be able to shove the error's information in a properly formatted log trace.
And quoting Edward Yang:
canonicalizing errors that the libraries you are interoperating [with are exposing] is a good thing: it makes you think about what information you care about and how you want to present it to the user. You can always create a MyParsecError constructor which takes the parsec error verbatim, but for a really good user experience you should be considering each case individually.
- Exceptional situations may be reported using
MonadThrow m
andException
instances - Add documentation about what additional exceptions may be thrown (and when) by a function.
- Use
safe-exceptions
to avoid confusion of synchronous and asynchronous exceptions. - Use
bracket
orResourceT
for resource management in presence of exceptions. - Make sure you do use
throwIO
and notthrow
if you are in theIO
monad, since the former guarantees ordering; the latter, not necessarily.
Could we make use of checked exceptions package here? Seems like not very active but still maintained, yet not much discussed and advocated in the community.
See Examples
-- BAD
takeN :: Integer -> [a] -> [a]
-- BAD
takeN :: Integer -> [a] -> Maybe [a]
-- GOOD
takeN :: Natural -> [a] -> [a]
-- GOOD
complexAlgorithm :: Input -> Either ValidationError Output
-- GOOD
complexAlgorithm :: MonadError ValidationError m => Input -> m Output
-- BAD
complexAlgorithm :: (MonadError ValidationError m, MonadIO m) => Input -> m Output
-- BAD
myFunction :: FilePath -> ExceptT MyException IO Handle
-- BAD
myFunction :: MonadIO m => FilePath -> ExceptT MyException m Handle
-- GOOD
-- | Does throw 'MyException', when ... (and any other type of 'Exception' really)
myFunction :: (MonadThrow m, MonadIO m) => String -> m Int
-- ???
-- Using something like checked exceptions
myFunction :: (Throws MyException m, Throws OtherException m, MonadThrow m, MonadIO m) => String -> m Int
- Michael Snoyman's Exception Best Practices,
- Matt Parson's posts Type Safety Back and Forth,
- Mark Karpov's article on exceptions,
- Edward Yang's 8 Ways to report errors revisited,
- which is based on Eric Kidd's 8 ways to report errors in Haskell,
- Another Snoyman's post on The Use of Void to express "impossible" states
When a type declaration includes more than one constraints, use tuple notation instead of double-arrow notation.
Parenthesis are not needed when there is a single constraint.
See Examples
-- BAD
processEffect ::
MonadAsync m =>
MonadTimer m =>
MonadThrow m =>
HydraNode tx m ->
Tracer m (HydraNodeLog tx) ->
Effect tx -> Generate signature comments
m ()
-- GOOD
processEffect ::
(MonadAsync m, MonadTimer m, MonadThrow m) =>
HydraNode tx m ->
Tracer m (HydraNodeLog tx) ->
Effect tx -> Generate signature comments
m ()
-- GOOD
processEffect :: Generate signature comments
( MonadAsync m
, MonadTimer m
, MonadThrow m
) =>
HydraNode tx m ->
Tracer m (HydraNodeLog tx) ->
Effect tx ->
m ()
processEffect HydraNode{hn, oc, sendResponse, eq} tracer e = do
-- GOOD
createEventQueue :: MonadSTM m => m (EventQueue m e) Unfold createEventQueue
Use a Red Bin to quickly identify, analyse, track and resolve defects in the process.
- When a defect is spotted, place a sticky in the Red bin area of our Miro board. A "defect" is anything that goes in our way: Inconsistencies in formatting, long build times, flacky tests, unclear names, ill understood code or tool...
- If there's already a similar issue, add a 👍 emoji to the sticky
- Work on the defect as soon as possible
- optional: Link to an entry in Logbook explaining the issue and if possible analysing it: Why it matters, what possible solutions there are
Why
Identifying and resolving defects quickly and relentlessly is a key practice to improve quality of both the process and the product