6 ms·
This is such bad advice that I honestly couldn’t tell if it was a parody or not until I read the comment section—it’s not. Attempting these design patterns is
by Avi-D-coder 2y ago
This is such bad advice that I honestly couldn’t tell if it was a parody or not until I read the comment section—it’s not.
Attempting these design patterns is a common part of getting over OOP when new to Rust.
The result: over-abstracted, verbose, unmaintainable C++/Java written as Rust.
Every layer of indirection ossifies the underlying concrete implementations.
The abstractions inevitably leak, and project velocity declines.
I have seen the same types and logic literally copied into three different repositories in the name of separation of concerns.
Luckily people usually get over this phase of their Rust career after a couple of failures.
If you’d like to skip that part, here are a few rules:
1. Always start with concrete types.
Don’t abstract until you have at least two, preferably three, concrete implementations.
2. Separation of concerns is a myth.
3. K.I.S.S.
- tcfhgj 2y agoI don't want to invalidate your experience, but I would like to see what your claims and conclusions are based on.
- flohofwoe 2y agoNot the parent, but what's really missing in the article is a complete code listing of what the initial code has been turned into after the refactoring to really hammer home the absurdity of the advice (fwiw I was also scratching my head for a while whether this is satire, because the end result would look a lot like 'Java Hello World Enterprise Edition: https://gist.github.com/lolzballs/2152bc0f31ee0286b722 https://gist.github.com/lolzballs/2152bc0f31ee0286b722). The original code fits on one page, is readable from top to bottom, and doesn't contain any pointless abstractions that worry about 'future problems' that never actually come to pass in the real world anyway. If the code no longer fits the requirements, no big deal, just throw away those 40 lines and rewrite them from scratch to fit the new requirements. That will most likely take much less time than understanding and modifying the refactored 'clean code', because there's a pretty good chance that the new requirements don't fit the abstractions in the refactored version either (and IME that's the typical scenario, requirement changes are pretty much always unpredictable and don't fit into the original design, no matter how well thought out and 'flexible' the original design was).
- tempodox 2y agoArchitecture astronauts have to learn this lesson the hard way.
- rob74 2y agoThe same applies to Go too. They may be quite different languages, but trying to apply OOP patterns feels alien in both of them...
- tcfhgj 2y agoDependency inversion is a OOP pattern?
- Avi-D-coder 2y agoYes, the dependency inversion principle is not a commonly held principle in FP or imperative paradigms.
- asimpletune 2y agoWow when I read this comment I did a double take and had to go to Wikipedia… then I realized dependency inversion is not the same thing as inversion of control and things made much more sense. I guess part of the confusion came from how dependency injection is a form of inversion of control… the words are all very similar to dependency inversion.
- aswerty 2y agoI think your identification of that distinction is entirely too generous. Typically the derision of dependency inversion extends to inversion of control since they are cut from the same cloth. One just focuses on what is being inverted and the other the process of inversion.
- deleted 2y ago[deleted]
- mrkeen 2y agoI haven't internalised what inversion of control means, but I'm very strong on the distinction between dependency inversion and dependency injection frameworks. With DI, you stop your business logic from knowing about your Postgres database. With DInjF, not only does business logic still know about Postres, but now it knows about Spring too! (The upside is that it takes fewer lines of code to get from 0 to spaghetti)
- ejflick 2y ago> 2. Separation of concerns is a myth I don't fully understand the quote from Djikstra where he first talked about this but I'm sure he didn't mean it as it's interpreted today: "draw invisible boundaries in random places because best practices."
- gpderetta 2y agoPremature generalization is the root of all evil. (yes, this is indeed a generalization)
- mamp 2y agoBut unfortunately it’s not premature. It’s been a problem for so long!
- globular-toast 2y agoKeep things as simple as possible, but no simpler. The point of this isn't to introduce complexity for no reason, it's to free up the domain model of an application from low level details like persistence, i/o, network protocols etc. What's the Rust way to do that?
- Avi-D-coder 2y agoWhat the author calls bad code is one way of writing idiomatic Rust. There are more complex techniques. It's recommended to not split the low level details from. your business logic, in fact it's not just recommended the compiler slowly forces your hand. If you write overly abstract code like the author recommends you will leave a large amount of performance on the table. Code like that doesn't play nicely with lifetimes, by trying to separate memory management from business logic you're left with only the least restrictive scheme owned heap allocated data. The Rust type system teaches you not separate concerns, without giving up the ability to reason about your code.
- vilunov 2y agoI'm sorry, but depending on abstract classes does not "free the domain model of an application of low level details". The details are always there, but they could be tucked away inside other classes (structs/types) that higher ones depend on. These low level classes need not to be abstract, it will just make discovering code harder and provides nothing to improve separation of concerns.
- globular-toast 2y agoI didn't say it did. Using abstract classes is not the goal. The goal is to free the domain model from the low level details. This can be done and these architecture patterns are supposed to achieve that. In other languages you use a lot of abstraction to achieve this. If this isn't how it's done in Rust then it would be good to know how it is done. I want the low level details to depend on the business logic, that's all. That means the business logic is clear and testable independent of any low level details.
- aswerty 2y ago> This is such bad advice that I honestly couldn’t tell if it was a parody or not until I read the comment section—it’s not. There is a large body of content on why concepts discussed in the article are championed. And also a large body of content on how they are misused (and I agree that can be - even to a huge degree). So while, I think it is fine to judge that ascribed benefits are not worth the cost (or are not even realizable in a typical development team). Or argue why the benefits of an architecture like this doesn't work for Rust in particular - which may be the case since many of these patterns are oriented towards design in the enterprise applications space. But ascribing the approach as being a "parody" is not at all constructive. The patterns of hexagonal architecture isn't in any way coupled to OOP even if some of the terminology is highly aligned with languages in the OOP space. In fact the well regarded (at least by me) Mark Seeman has an article how Ports and Adapters, another name for Hexagonal Architecture, is inherently functional [1]. And this resonates with my experience. I have seen the pattern implemented well across: Python, Typescript, Javascript, .Net, Scala, and Go. And while the systems languages such as: C, Rust, and beyond are quite distinct from the previously discussed languages. There is certainly space for debate on the viability of the application of these patterns. [1] https://blog.ploeh.dk/2016/03/18/functional-architecture-is-ports-and-adapters/ https://blog.ploeh.dk/2016/03/18/functional-architecture-is-...
- BlackFly 2y agoThis sort of response on second thought seems like a knee-jerk, but in the off chance that you might be open to seeing the perspective that values a hexagonal architecture. You always have two concrete implementations: the production application and the testing application. Otherwise you yolo things into prod, or run only manual/integration tests. That can work for a while, but many people find it unsavory. It is pretty easy and sometimes useful to make three implementations: http server, CLI, test. Maybe you want to use files in CLI and a db for a server. It has always been a good idea to isolate persistence and transport concerns from the business logic and that doesn't change in Rust. Don't dependency drill SQLite up and down every call stack. If your application is small enough and will stay so, then separating it is more a question of habit than anything. But you shouldn't abstract everything nor try to separate everything! It was a persistence layer before, in the hexagonal architecture it becomes adapter implementations of a port. Transport layer is similar. Separation of concerns in this case means that you have a concrete dependency (an http server, a db etc) that isn't part of your logic.
- hitchdev 2y agoThe best way to conceptualize hexagonal is as a kind of crutch to accomodate the inability of unit tests to effectively fake stuff like the db and their tendency to tightly couple to everything. It's not intrinsically good design but it does improve unit testability (which sometimes has value and sometimes has zero value).
- Avi-D-coder 2y agoI am partial to property testing logic and integration testing servers. This frequently requires some level of separation, the key is to do it only at the right points. Don't start by saying how can I unit test this tiny bit of logic against several mocks, start with a simple integration test of your real routes. As you add abstractions you are trading maintainable straightforward code for more granular testing. It's a hard trade off not a set of principles for good code.
- BlackFly 2y agoNot once did I mention unit testing. What I mentioned was an adapter and port. If you can only run against a postgres database, your integration test is going to require setup and be slow. If you can easily swap the postgres adapter out for sqlite or in memory, the same test will be practically instantaneous and self contained. The same test can be occasionally run against a postgres database (like once a night), to ensure there are no postgres specific idiosyncracies. Thus I started by saying how you can integration test quickly without mocks (sometimes it would be called a fake, but using a different db is something else) on real routes.
- seanhunter 2y agoYeah. The hallmark is they reference some document by Martin Fowler. That's a red flag for me. I would add - one of the symptoms of this type of over-abstraction is what you could call the "where tf" problem. Any time you need to do anything it's really hard to figure out where 1) ...the thing you're trying to fix actually happens so you can fix it 2) ...the new feature you're trying to add should actually be added 3) ...it's going wrong when it's going slow/not scaling/stalling somehow ...because in reality the answer to the question "where" is always "all over the place". And that means you typically need to make several small changes in various places to do anything. So you've papered over the intrinsic complexity of the system and you have a really nice looking whiteboard but the complexity is now distributed in a bunch of places so it's conceptually complex and much harder for a dev to actually get their arms around the whole system and understand it fully. And you now have a false sense of security because you have great test coverage but the kind of problem you now face isn't caught by tests. Because typically the type of problem you hit is you should have made 6 changes in different places to implement your feature but you've forgotten one and only implemented 5. So the system is now semantically broken in some way even though all the tests pass.
- jiggawatts 2y agoYeah, this article is the poster-child for "engineering for an unlikely future at the expense of the certain present". > If you ever change your HTTP serve Yeah, that ain't happening. You also won't replace your database engine or queue either. If you do for some reason, it'll be a partial rewrite of you app no matter what. You can't abstract over these things in a useful way, the common denominator isn't rich enough for useful applications. > You'd have the same problem if this code lived in a setup module Moving code from file A to file B does nothing to it. This is the fallacy of assuming that the name of the thing changes the thing, and is in the same vein as having a "secure" network where it's secure because it is called that in a spreadsheet of subnets. > our HTTP handler is orchestrating database transactions Transactions are deeply linked to requests. If you try to abstract this away, you won't be able to read your code any more because you won't be able to see the control flow in... the controller. > You cannot call this handler without a real, concrete instance of an sqlx Sqlite connection pool. Faking a database is a fool's errand. It's a lot of work at best, and a subtle source of false negatives or positives in your tests at worst. Database engines have very complex behaviours such as concurrency, transactions, locking modes, type conversions, collation, and so on. Why try to emulate this!? Just use a local database file for testing! A bigger concern with the repository pattern is that without eternal vigilance, it'll block the use of high-performance code. For example, with the Author repository, retrieving authors is all-or-nothing. The blog author used sleight of hand to hide this by having a single "name" field along with a primary key. Okay, what if there are 287 fields, and a bunch of foreign keys? Now what? Do we read in the 1 name field along with 286 unrelated fields just to throw all that work away? That's 0.35% useful work performed per call! Similarly, he returns single authors, one at a time. How do you returns collections in response to queries? As Iter? A Vec<Author>? What if it's an async streaming response!? If you try this, you'll quickly discover that there is no general portable pattern across different DB providers and every approach has some downside. That downside can be "OOM panic" or similar. I've been doing a lot of work recently to clean up legacy ASP.NET apps and my #1 trick to directly invoke Entity Framework directly in the controllers (HTTP handlers). I select just the required columns, run the queries as async, and where possible/useful I stream back the results instead of trying to hold them in memory at once. I've seen 5-10x speed ups compared to SOLID pattern code with everything broken out across dozens of interfaces, abstractions, and layers scattered across a bunch of projects. All this with a 30x (no joke) reduction in lines of code, dramatically faster builds, faster deployments, and readable code that can actually be maintained by one person. I reduced one project that had several thousand lines of code to one page, the same kind of thing as the "bad" everything-in-main example in this blog post. Was I bad and wrong?