Yeah that's one of those aforementioned "moving parts". You design the interface with a single action taking html and returning pdf. That way it doesn't matter how you change these moving parts, you can replace this pdf library easily and as long as you find another way to make this thing take html and return pdf you're golden, none of the code using your pdf generator ever needs to change no matter how much you need to change this service. Unless you change its entire purpose - converting html (and maybe other stuff) to pdf - none of its consumers need to change.
That's the idea. You define a clean and well-defined interface beyond which everything is hidden. This keeps the code simpler because you can focus on one little part and know nothing you do will change anything anywhere else. Its purpose is well defined.
As an example of a less well defined service I'm currently working on a class DiffService which has a method which gets data from a DB, then it gets data from another DB, then it gets data from an API and then it checks for differences between the two db datasets and uses the api data in the process somehow, I can't remember atm. In my opinion there should be one class where all the data is put together, then it should send the data into a different method in a different class where the only concern is taking the data and combining it in the required way.
This way the actual logic is separate from the data collection. It sucks trying to test this logic because I have to mock a bunch of different services and then test the logic. If the concerns were separated I wouldn't even have to unit test the data collection service - it's just calling some data collection methods and sending the data on to this new service which I could then easily test without mocking anything. I might also separate the part with that api call to yet another place if I can. That way I can have a completely dedicated DiffChecker with super simple and understandable logic, a SomethingDoer which takes that api data and does whatever it needs to with that, and a more general service class tying all these parts together. I can also use these parts in a different part of the code that does almost the same thing instead of having the logic duplicated.
I don't really need any new interfaces for this refactor - abstraction isn't exclusive to interfaces. The code will be more spread out so that does take a toll in terms of mental load but I think that is alleviated entirely by the simplicity of the new components - DiffChecker checks for diffs in provided data, it doesn't do anything else. SomethingDoer does some specific little thing I can't remember at the moment but it'll be equally simple in principle. DiffService ties the whole system together and provides an easy way to get the difference - just like it used to before. It'll just be easier to test, the new tests will be clearer which allows them to more clearly convey the purpose of the code they're testing, the new code will be reusable and allow me to easily perform a similar refactor of a similar system and reduce the total amount of code.
Some people will probably say I'm overdoing it, I think they're wrong. I think a class with one method that does one simple thing is extremely easy to conceptualize, allowing you to stop worrying about what's inside it. Meanwhile the existing solution is extremely difficult to completely wrap your head around. It gets all this complicated data, then it does multiple different operations on it before it returns a result. I have worked with this code for a bit, I've read this method at least 20 times trying to fit everything in my head and it's a struggle. It may sound simple but every part has complexity and nuance in the way they're put together and it adds up. To me it becomes much easier to fit everything in my head when I compartmentalize it into small simple parts that are easy to understand and are put together in a clear and cohesive way.
var dataA = db.GetDataA();
var dataB = db.GetDataB();
var diff = diffFinder.Diff(dataA, dataB);
var apiData = apiClient.GetData();
var enrichedDiff = diffEnricher.EnrichSomehow(diff, apiData); // This would have a more descriptive name
return enrichedDiff;
This is what I want my code to look like. This is already plenty busy, there's a lot of stuff happening here. It'll also have logging. Imagine trying to read it if it was 100+ lines because everything was written directly in this one method.
That's the idea. You define a clean and well-defined interface beyond which everything is hidden. This keeps the code simpler because you can focus on one little part and know nothing you do will change anything anywhere else. Its purpose is well defined.
As an example of a less well defined service I'm currently working on a class DiffService which has a method which gets data from a DB, then it gets data from another DB, then it gets data from an API and then it checks for differences between the two db datasets and uses the api data in the process somehow, I can't remember atm. In my opinion there should be one class where all the data is put together, then it should send the data into a different method in a different class where the only concern is taking the data and combining it in the required way.
This way the actual logic is separate from the data collection. It sucks trying to test this logic because I have to mock a bunch of different services and then test the logic. If the concerns were separated I wouldn't even have to unit test the data collection service - it's just calling some data collection methods and sending the data on to this new service which I could then easily test without mocking anything. I might also separate the part with that api call to yet another place if I can. That way I can have a completely dedicated DiffChecker with super simple and understandable logic, a SomethingDoer which takes that api data and does whatever it needs to with that, and a more general service class tying all these parts together. I can also use these parts in a different part of the code that does almost the same thing instead of having the logic duplicated.
I don't really need any new interfaces for this refactor - abstraction isn't exclusive to interfaces. The code will be more spread out so that does take a toll in terms of mental load but I think that is alleviated entirely by the simplicity of the new components - DiffChecker checks for diffs in provided data, it doesn't do anything else. SomethingDoer does some specific little thing I can't remember at the moment but it'll be equally simple in principle. DiffService ties the whole system together and provides an easy way to get the difference - just like it used to before. It'll just be easier to test, the new tests will be clearer which allows them to more clearly convey the purpose of the code they're testing, the new code will be reusable and allow me to easily perform a similar refactor of a similar system and reduce the total amount of code.
Some people will probably say I'm overdoing it, I think they're wrong. I think a class with one method that does one simple thing is extremely easy to conceptualize, allowing you to stop worrying about what's inside it. Meanwhile the existing solution is extremely difficult to completely wrap your head around. It gets all this complicated data, then it does multiple different operations on it before it returns a result. I have worked with this code for a bit, I've read this method at least 20 times trying to fit everything in my head and it's a struggle. It may sound simple but every part has complexity and nuance in the way they're put together and it adds up. To me it becomes much easier to fit everything in my head when I compartmentalize it into small simple parts that are easy to understand and are put together in a clear and cohesive way.
var dataA = db.GetDataA();
var dataB = db.GetDataB();
var diff = diffFinder.Diff(dataA, dataB);
var apiData = apiClient.GetData();
var enrichedDiff = diffEnricher.EnrichSomehow(diff, apiData); // This would have a more descriptive name
return enrichedDiff;
This is what I want my code to look like. This is already plenty busy, there's a lot of stuff happening here. It'll also have logging. Imagine trying to read it if it was 100+ lines because everything was written directly in this one method.