Like "DRY", "YAGNI", SOLID, short vs. long functions, how to test, minimizing vs. maximizing reuse, static vs. dynamic typing, cyclomatic complexity, interfaces, functional programming, immutability, etc. You should definitely be familiar with these principles and their tradeoffs.
My advice though would be to take them all as suggestions, not absolutes, and to look at your specific requirements of your project, what you know about it and your customers (how it's likely to evolve over time, how it has already evolved, how your company will evolve, who the engineers are, etc.), and then find & adjust the principles that apply. There are no silver bullets here, and which techniques are successful depends very much on the context in which they're used. Work backwards from your team, your situation, and your customers.
It's very tempting to make it "extensible" by overgeneralizing. What you get then is overly complex code, most of which is never used.
Cut out everything that isn't currently used, and you have a small system where every line pays for its keep.
It's always easier to change a small thing instead of a large thing. So make a small thing, and only make it a big thing if you absolutely have to.
I think my favorite recent HN example of this was that article where Discord rewrote their "unread message count" service in Rust. They were able to make a huge change to a critical section slowly and incrementally because it was loosely coupled. The programming language they chose didn't matter, because it could just be changed. The implementation didn't matter because the consuming code didn't know about any implementation details. The change happened and all the other teams had to care about was that there weren't weird latency spikes anymore.
One area you can quickly see this in action is with user interfaces. So many modern applications construct their UI tediously by coding each component and all of the aggregations by hand. If you take a step back, it's usually very easy to see that the configuration of the UI is just data. Why not just build one layout "engine" and feed it data to generate your UI? The engine part can use whatever hotness you like, but you only have to write that code once. The configuration of the UI (the policy) is what changes frequently (the business requirements); not the mechanism underneath. (This doesn't mean you won't have to change the mechanism, you will. It just requires change at a much slower pace and the changes are bound within the context of the mechanism itself.)
This approach separates layers of a system which have different change vectors and velocities. The mechanism tends to have a much lower rate of change and it's where we programmers like to live. Build tools for "the business" -- whatever that means in your context -- so they can maintain as much of the policy as possible.
If you think through designs like this; however, you will realize that our modern fascination with massive "scaling" of teams is no longer attractive. This may explain why it has fallen out of favor.
[1] https://en.wikipedia.org/wiki/Separation_of_mechanism_and_po...
Those who don't understand Unix are condemned to reinvent it, poorly. - Henry Spencer
Many architecture tips here: https://github.com/globalcitizen/taoup
Thus there aren't many simple always-applicable tips. Every tip requires experience to know when you apply it. Any tip that doesn't require that experience ends up not accomplishing much.
Keeping things simple is good. But sometimes you do need some abstractions, scaffolding, and structure that sacrifices simplicity but enables the code to grow.
Tests are good. But sometimes code can be so heavily tested that changing the tests themselves is a large maintainence burden when the requirements change.
Abstraction and encapsulation are good. But both add complexity and raise the amount of code someone has to load into their head to understand what's actually happening.
Documentation is good. But writing good docs itself an artform, and docs that aren't maintained well can cause more harm than good.
The best advice I can give is to not seek out "tips". Just work with the best engineers you can find, write code, reflect on it, and trust that your experience and intuition will grow over time. There is no shortcut to mastery.
DO NOT try to generalize and abstract until the third time that you are writing something. (Having written it for other companies counts.) Until the third time your guesses about the right generalizations and abstractions generally work out badly.
Focus on simple over clever. I cannot count how many abstract systems that I have seen which were designed to achieve some general purpose reusability. They almost never actually achieve the asked for dream and almost always become a barrier to comprehension by other developers.
FOLLOW ALL STANDARDS IN YOUR ORGANIZATION. Code review. Unit tests. Formatting. Naming conventions. Doing what you think is the perfect thing is almost always worse than staying consistent with other developers.
Example: if a computation requires some intermediate value, create a new variable with a proper name and assign the intermediate value to it, instead of just inlining it in a bigger statement or assigning it to "res". The compiler knows how to deal with temporary variables like these in an optimal way.
Sure you can assume that all the requirements will change, but then you’ll need to make a system so flexible it becomes a nightmare to do anything (just look at JIRA or other software like it, with 100 configuration options for every piece). These systems require vast resources to maintain because there is so much code to make it flexible.
You can also assume the requirements won’t change at all, tightly following the spec and optimizing all the edge cases so you have a pristine black box no one can touch.
Finding the happy medium involves knowing which assumptions are likely to change, and which aren’t. Usually, languages won’t change often, so you can rely on most of those features. Certain well-maintained libraries and APIs won’t change much, so those can usually be baked-in too. The business logic around things like pricing, users, analytics, data visualization, and data processing are likely to have changes. My favorite one for web apps with databases is search. The filters very often change and grow, so getting a highly flexible search working is usually a big win for not too much effort.
Note, I’ve never said always. If you can get knowledge about the business needs you’re supporting, you’ll have a much better idea of which things need flexibility, and which things don’t. The longer the roadmap on a project, the more wins you can get when architecting systems too, as you can set yourself up for easier implementation of future features.
It should be removable in a way that does not break other systems and it should be easy to identify when "it" ends and when something else begins
Also make the code easy to read and understand, have good naming convention for the small functions and variables, add comments if you have to do something unusual (like workarounds for weird quirks or bugs) so it makes sense 3 months later when you read the code again and you forgot most of it.
* Write functions/units that are <= 15 LoC.
* Create units with <4 branching points
* Write functions/units with <5 parameters
These guidelines force you to write maintainable code. Always open for discussion. Note that these lessons are lifted straight from [1].
[1] https://www.softwareimprovementgroup.com/resources/ebook-bui...
While you're adding a feature it's always tempting to go down the "what if" route and make some kind of crazy interface that is extensible and able to accept plugins and all that. The problem is you're likely making it extensible in ways that turn out to be useless down the road.
Just code the least amount you have to in order to achieve the current goal. That way down the road it's easy to delete what you did, or code up some changes to it. I personally think the worst thing you can do is write code that is all things to all possible future features.
- Database models should contain all the possible mechanisms/methods for data I/O
- Data Transformation utilities/ helper functions that do one job well, that is - sort, filter, validation, whatever -- -- If an API is using (more on API below), utils become immutable code
- Finally the API level -- -- treat the API code as immutable, once shipped -- -- The code should look like a pipeline of functions -- -- -- eg: serialize(...) validate(...) filter(...) db(...) -- -- -- need not be chained -- -- new API version, new router, new routing logic
- Review API with an intent to extract out any possible util methods. final API methods should look like a series of data transformation functions
- Overtime the utils would become extremely messy, but the APIs would be very clean
------------------------------------------------
The layer running business logic would be the most interacted with layer, if you can keep it clean, then you can keep frustration low in your fellow devs. A lot of people can work on the API level, irrespective of their experience.
Utils and Models should be managed by more experienced devs. Rate of change in Utils and Models is quite low as compared to the rate of change in the API levels.
------------------------------------------------
Not sure, I guess I am writing something very trivial and obvious.
- functions that do several different things
- tight coupling
- implicit behavior
- too much indirection
- too many thin custom wrappers
Often several of these come together when an inexperienced developer factors out everything they can (over-eager DRY) but still tries to keep a few highly specific things "easy". One thing I see often is modules or classes that wrap a standard library, adding code essentially removing features in the interest of "ease-of-use".
I think you can write better code if you write code in multiple passes: only write what you need right now, and use the code as-is between each pass. With that approach, you'll gain some real intuition about what is easy to read and modify, which is much more helpful than someone's list of tips.
That said, I've found that writing simple explicit functions that take arguments and return a result can get you pretty far. Same for class constructors and methods, API endpoints, etc. Try to avoid writing "convenience" functions or classes when just using the library directly with some arguments would suffice. In other words, boilerplate probably isn't as bad as you think, and the indirection/implicit behavior/tight coupling is often worse than the boilerplate.
* If in a loosely typed language go way out of your way to ensure conventions are in place to identify things by data type. TypeScript has completely changed how I look at JavaScript particularly its use of interfaces for defining objects and data structures.
* Limit your use of dependencies as much as possible. In my current personal project I am down to 1 dependency. Dependencies will weigh a project down and prevent nimble shifts of direction. This also includes frameworks and tools.
* Write good documentation and keep it up to date. Automate the authoring of documentation where you can so that it stays up to date with less manual intervention.
* Isolate concerns as much as possible so that a feature can be deleted and replaced with as little effort as possible.
* Keep things simple. This requires lots of extra effort both in planning and refactoring. When there are fewer pieces and everything is as uniform or consistent change requires far less effort and leaves far less dead code in its place.
* Don't abstract things for convenience or easiness. Abstractions are incredibly helpful when they result in simplicity (fewer pieces and fewer ways of doing things). Absolutely don't use abstractions as a means of being clever or for vanity reasons. Then you just have extra code and the larger a project gets the harder it is test and change.
* When other people want to impose their opinions on you push back until they come back with test automation and/or strong provable evidence of a better way of doing things. If they still try to impose their opinions on you, and this will happen, kindly tell them to go fuck themselves. So much of the stupidity in programming comes from baseless irrational subjectivity.
* Test automation. Its not about how you test, but about what you test. There is a lot of nonsense out there about how to test. In my current personal project I have automation in place for code validation, compile checks, command tests in the terminal, service tests. Soon, as I figure it out, I will be adding test automation for user interaction and user data storage. However you are able to execute all the kinds of tests you need just do it. The goal is to promise your software can deliver a feature and prove it with test automation and then add more tests later to cover for edge cases you did not consider.
I usually keep a Google Docs page open where, as I write the code, I update the documentation. It keeps things consistent and flexible, and much easier to go back and refactor.
This includes:
Easy to understand variable and function names. IDEs make autocompletion possible so don't fear long names. MyEasyToUnderstandFunction() is better than MyETUF().
Function names should describe what they do. CreateOrder() is better than NewOrder().
Make sure you don't use global/public state in any object or component. Public state is one of the biggest sources of failure and bugs. If you can, don't use it.
KISS: Keep It Simple Superstar!
But maybe the most important (for me): use separation of concern and context. This always helped me a lot. For example: if an email must be sent when a new order is created you can put the email code directly after the order creation code. This will ofcourse work and might even be valid code. But the component that creates the order should not be bothered with how emails must be sent. If you separate this, it will help you a lot to create maintainable code.
It sounds like the same thing, but acknowledging that mistakes happen no matter what and trying to focus on preventing them is something much more tractable that trying to come up with the perfect thing that can be understood right away.
For instance, I used to be a fan of long variable names that say exactly what they do. I still am, but only in certain situations. In fact, I'd argue that using very descriptive variable names in the wrong situations can actually make the code less maintainable, not more.
This takes us naturally to a discussion about what works, why it works, and under which circumstances it might not work (or actually hurt), which is probably a much more productive conversation than something along the lines of "how do you guys think the best way to code is?"
We do consulting work and have to build custom solutions for our clients. Once they were satisfied with the models, they'd ask for an application to use these models: user management, data uploads, etc.
We used to build a custom solution for each client. It was very hard to change these solutions when client's demands changed, and it was almost impossible to reuse that solution with another client.
We took advantage of a decrease in activity to build a platform where things would be plugins. The time series forecasting application is a plugin, with endpoints you can tap into with authorization tokens.
The recommender system is a plugin, the notebook application is a plugin, etc.
Now our notebooks are "Published" in one click, which generates an AppBook a business person can interact with by changing the parameters. The runs are tracked (params, metrics, and models) in case domain experts tweak parameters that result in a better model than we have. The models can be deployed to endpoints you can invoke with generated tokens.
The major benefit is that when a new member joined the team, they had to understand the whole code base to be able to contribute or add something. Now? They don't have to understand anything, only the application they're building. If they want to render it on the sidebar, they just have to add a `sidebar.html` with the proper icon and route. If they want to show it in our appstore, they just have to add a file with the proper icon, and our platform handles the rest. It will discover the application, load it, etc.
An administrator can even load an application as a zip file. The platform will consume it, install its dependencies, load it, and activate it.
We want the product to be easy to add to and modify its behavior with config, instead of changing the code and we work to do just that.
You should try to express problems elegantly, and that will help make some changes easy. That's nice when that happens, and it does happen quite a bit. But many changes will still not be straightforward.
Generally, write the code to do what it's meant to do, and when it needs to change, rip it out and rewrite it.
It's shockingly easy to write something that's concrete and later realize "I need two of these" and then quickly factor out the common functionality.
Especially, don't kludge things. The problem with "easy to change" is it means "easy to kludge just one more feature on here." That won't remain easy to change. Refactor aggressively and your code still won't be easy to change, but it won't become a kludgey mess that's hard to change, either.
Keep in mind you can always start over, especially if you componentize your work!
As you prototype, or just design by drawing on whiteboard, learn your domain and gain ideas what would improve your initial lack of design.
Iterate and refine when cost effective to do so, not sooner. If still on whiteboard, you could benefit from implementing rapid no-code solutions before diving into any code at all!
Resist committing to structure, unless your experience and insights into the domain require it and you see clear benefits from doing so. Deconstruct structure you find bring unnecessary complexity, as well as build on structure when doing so clearly brings leverage.
Care for your code.
Sometimes its easy to predict how your code may need to be extended if you give it a bit of thought. In those cases, it might make sense to allow for easy extensibility. This is pretty rare though. Most times, you can't really predict what will be required of the code in the future. In those cases I prefer to bake as few assumptions into the code as possible, so when someone else is extending it in the future, they spend as little time understanding your code.
If you are writing an interface to another system of any kind (DB, API, etc.), abstract that interface one layer from other parts of your app. Then when that other thing changes you are less impacted.
An example: if your organization has multiple apps, it might sound easy to just let app #2 have direct DB access to app #1's DB. Take a little time and build a simple API in app #1 to get at the data, then when you change column names, etc. as long as your API layer doesn't change app #2 won't require changes. You don't have to build a fully RESTful API to all-the-things if it is just one table. Iterate.
For every additional change, I look at the module as a whole again, and keep thinking "If we got a new hire, how easy would be for the person to understand this in the bigger picture?"
Understanding is all about mindset. If part of the code starts to do too much, then other developers are likely to make incorrect assumptions of the logic behind that piece of work. The small mistakes slowly compounds over time, and if not taken care of, the whole project becomes un-maintainable.
At the end, if you comments and logs are complete and self explanatory, any one can come in and change the code or debug the code.
Said another way: Making an easy to understand interface to your software should be your top priority. Make the interfaces within your public interface as easy to understand, etc., all the way down.
Easy to test: Easy to understand. That makes it easy to change.
Anyone can read the code and see what it does.
No one can read your mind to figure out what you intended it to do.
https://programmingisterrible.com/post/139222674273/write-co...
- Code is written once and read at least once, have empathy for the readers...
- If code cannot be understood, it cannot be changed safely...
- Less dependencies on other code, makes code easy to understand...
- The smaller the context is, the easier it is to understand the code...
[1] https://en.wikipedia.org/wiki/Interface_(computing)#Software...
The functional paradigm is the most modular style I've seen. Modularity is a side effect of immuteability.
This menas ability to change specific behaviours wih very limited side effects.
By using TDD methodology (structure your code to support it, not necessarily obey the principle head to toe), your code will be 80% of the way there.
If the word "architect" springs to mind then you are doing it wrong
If it's easy to delete, then it's easy to replace.
And if it is easy to delete then it should be naturally loosely coupled.
A lot of the times they don't mean simplify, they mean compact code.
I write verbose code, as explicit as possible. Sometimes it looks like it was written by someone just learning to program. And that's on purpose to make the code as easy to understand and change as possible.
TL:DR: Be skeptical about code "simplification" and don't fall on using fancy language features when if/else conditions do the job.
don't get trapped by being "too DRY"
Keep data and logic separate, data-driven development.
Adhere to the "standardized data interface specification" and use pipeline-functions to manipulate data from the initial state to the final state.
Pipeline-functions conforming to standardized data interfaces are easy to change, replace, and insert extensions.
In the web model, It can be easily replaced as long as the components that conform to the http data specification. For example, Clojure web application model:
- product standard (data interface): the req-map and resp-map of the ring
- warehouse: the ring
- workshop: the pipe functions on both sides of the C/S, and Raw materials (hash-map) are transferred to each other through warehouses through interactions.
https://github.com/linpengcheng/PurefunctionPipelineDataflow
There is an objective way to tell if design A is better than design B, and that is to measure how hard it is to make changes. You have to build that into your culture and not optimize for something else, or do things because somebody thinks that is the way to do it.
A big problem in current architectures is that what seems to be a small change (say, add a "cell phone" field next do a "home phone" and "office phone") field often requires a number of little changes in widely separated code:
* a database schema change * changes to database queries * changes to a back-end API * changes to a front-end Javascript app * changes to an iOS mobile app * changes to an Android mobile app
Conventional system design means that what looks like one change to management is actually at least six changes, which might affect that many source code repositories, etc.
An ideal design would let you create a feature by writing all of your code in one place. Of course this would require a big change in how applications are structured and built, but it could lead to a durable advantage. See
https://en.wikipedia.org/wiki/Software_product_line https://en.wikipedia.org/wiki/Feature-oriented_programming https://en.wikipedia.org/wiki/Low-code_development_platform
The biggest barrier to the above I think is that when you talk to a team they always have shiny objects that they can't live without. Maybe they think functional programming is great (yeah, you can handle errors with monads, but you can drop errors on the floor just as easily as you can with exceptions, return codes, etc.) or that immutability solves everything, JDK 13 is the best, whatever. So people are thinking about everything except the thing that management will care about which is long-term sustained productive development.