There are some very simple rules I follow that make code easy to read, reason about and change as I write software. I like how the Phoenix Framework encourages the use of Contexts to describe how to organize code into specific modules and functions. I even like their naming conventions.
I think these ideas can be used in any project that models logic that we encounter in the world. In fact, I used many of the ideas in my Ticket to Ride implementation.
It's hard to make code (or APIs) easy to read in the very beginning
This is always a challenge for me when I don't know the full breadth of what I am building. It's like writing a draft for a story or blog post. I experiment with words and code organization and only after some time do things start to fall into the right places.
In general, I try not to stress too much about it because I stick to a few tenets:
- Use
verb-noun
style function names - Don't use corporate sounding words to name your modules (like
manager
,strategy
,factory
, etc.) unless they are explicitly described in your domain. - Write
typespecs
for every public function (even if it is a resource under your domain) - If a map is getting passed around, consider giving it a formal type as a
struct
. - Alias any internal, dependent modules you use in a module.
Using verb-noun function names
In Ticket to Ride, I use the following verb prefixes:
- add
- begin
- create
- deal
- destroy
- draw
- find
- get
- has
- join
- leave
- list
- login
- logout
- perform
- register
- remove
- replace
- reset
- return
- select
- setup
- shuffle
- start
- stop
This is a lot of verbs, but they identify a specific action. That specificity helps me understand what a function is going to do. If I only had the noun as the function name, does it indicate to me what is going to happen or what side effects will take place? If I use a vague verb like perform
, should I consider renaming it to something else?
Naming modules that make sense for your problem domain
I stay away from corporate sounding module names like strategy
, manager
, etc. because I see them as misleading patterns that distract from a module's purpose. The module name should indicate the domain or resource and nothing else. Let the function names define what its abilities and purposes are.
I think Paul Graham was onto something when he said this in Revenge of the Nerds:
When I see patterns in my programs, I consider it a sign of trouble. The shape of a program should reflect only the problem it needs to solve. Any other regularity in the code is a sign, to me at least, that I'm using abstractions that aren't powerful enough-- often that I'm generating by hand the expansions of some macro that I need to write.
Write typespecs
I see this language feature is as powerful as writing tests. Not because they correct me when I do something wrong, but they show me that I have gone onto a path of complexity that feels like it may cause me trouble later. For example:
@spec claim_route(State.t, User.id, Route.t, TrainCard.t, cost()) ::
{:ok, State.t} | {:error, :unavailable}
This is from TtrCore.Mechanics.claim_route/5.
When I write a typespec that looks like this (more than 4 arguments with varying types), I start to feel uncomfortable and I try to find ways to simplify it.
What I do like about this is that it shows me exactly how varied my types are and I can reason about either removing them or combining them before writing any code. It's harder to do this without type information.
In addition, I can also reason about my possible outputs. Currently, these look reasonable.
Assigning types to structured data and passing it around
After combining a bunch of types into some compound type (like a map
or tuple
), I will pass that around in my internal APIs to get work done.
If the amount of work being done with the that data has two or more domain-based operations, then I feel like my data should be formalized into some named type.
Sometimes this data has to leave a domain and has to go into another one and I think that's OK as long as the operations in the foreign domain aren't trying to act directly on the data in a way that cause its meaning to change.
See https://hexdocs.pm/phoenix/contexts.html#returning-ecto-structures-from-context-apis as one explanation for this type of reasoning.
If it is needed to read that data in order to derive new data in a foreign domain, then that seems OK because that new data is relevant to that foreign domain and can be formalized as a type there.
The place this becomes dangerous is when a type needs to get associated with "extra" data and the "extra" data is merged directly onto the compound data type across multiple domains and still gets called by the same named type.
This is wrong. It begets all kinds of misunderstanding of what is happening in the code. If there is a need to do this type of association, a new type needs to be created to encapsulate both of these pieces of information or the original type needs to be modified.
I tend for the first option because it prevents a single type from become a "god" type that knows about too many things.
Alias internal modules in your modules
I like to know what my dependencies are in a module at the very beginning. Aliasing offers this as an excellent side-effect to shortening names used in module.
When I see lots of aliases in a particular module, can I assume that my module knows about too many things? Is it a sign that a new domain or resource should be created to handle a portion of these concerns?
Still no silver bullets
None of these tenets are silver bullets. They won't save me from poor performing code or convoluted logic that exists in the real world, but they do shield me from my own disorganization and give me enough principles to manage code bases over many years.
Top comments (0)