Best Practices for Scaling Up Scala Codebases – Tips for Performance and Maintainability?

Hello Scala community! :wave:

I’ve been working on a large-scale application in Scala, and as the project grows, I’m noticing some challenges in keeping the codebase clean and efficient. I’m particularly interested in best practices that have worked for others when it comes to performance optimization, code maintainability, and managing complexity in larger Scala projects.

Here are a few areas I’m focused on:

1. Optimizing for Performance

I’ve implemented some basic optimizations, like avoiding unnecessary object creation and using lazy evaluation where possible. However, with the increasing data load, I’m wondering if there are more advanced techniques or tools you’d recommend for performance monitoring or optimization specifically suited for Scala?

2. Functional Design Patterns for Readability and Maintainability

I find that functional patterns like monads and immutability really help keep things clean, but as the codebase expands, they can sometimes add layers of abstraction that become difficult to manage. Has anyone found a good balance between functional purity and practical readability in a larger codebase?

3. Testing and Debugging Large Codebases

With more modules, testing and debugging are becoming more complex. I’m using ScalaTest and have set up some property-based tests, but I’d love to hear about other strategies or tools that make it easier to test and troubleshoot in large Scala projects.

4. Dependency Management

Dependency management is another area where things are starting to feel a bit unwieldy. Any tools or best practices for managing dependencies and keeping the build fast and reliable would be incredibly helpful!

If you’ve worked on scaling up a Scala project or have some go-to practices for improving performance and keeping the codebase manageable, I’d really appreciate your insights. Thanks in advance for any tips or advice you can share!

Performance

Once you run out of the usual tricks of using good algorithms and not doing unnecessary work:

  1. Use primitives or arrays of primitives where you can and it matters.
  2. Use mutability to avoid algorithmically expensive immutable data structures. Make it interior if you can, with a smart interface that’s hard to get wrong.
  3. If you can use Scala 3, take advantage of inlining, boundary/break, and opaque types to reduce the overhead of objects that are only there to get your control flow right. Be careful if you are relying heavily on those objects for your control flow.

Functional design patterns

Assuming that you don’t make things monads or applicatives just for fun because you can, it’s a challenge. The algebraic effects systems seem promising but I’m not sure they’re adequately battle-tested yet.

I think the big issue with abstraction is that it tends to leak in for stylistic reasons rather than because it is pulling its weight in simplifying what needs to happen.

Testing and debugging

Write as much as you can as modules with good unit tests. Reducing fiddly dependencies between modules really helps with debugging because the problems become separable.

It also helps if you don’t rely on stack traces but pass back information about why things go wrong when they go wrong in your error types.

I pretty much never use debuggers. With cleanly separated modules and robust errors giving context about what happened, they don’t really add anything.

Dependency Management

My solution is not a good one: I take on way too few dependencies and rewrite too much. Don’t do that!

3 Likes

I think that’s worth stressing as a point #0, because all too often that’s skipped over.

We’re all told that premature optimisation is the root of all evil - I won’t dispute that - but after firing up the profiler, I’d say resist the temptation to immediately start optimising the hotspots. You can often avoid them altogether by using a more efficient algorithm, or make them merely lukewarm by avoiding repeated evaluation of shared code paths, which I think is what @Ichoran is saying.

It’s an iterative process.

On the subject of performance, one way to make really badly performing software is to decide upfront on some high-performance technique and then design around that. Try experimenting with various ideas and do some benchmarking science. Be prepared to be surprised, and have to throw away a lot of appealing ideas.

It helps to be able to break things down so you can do these experiments one piece at a time. I often misuse property-based tests for this, because I’m lazy and they provide a nice way of hammering your SUTs with lots of scenarios of varying complexity in addition to their real job of testing correctness.

Having some dedicated lower-level benchmarks definitely helps, though.

Inevitably, you will have to run some end-to-end benchmarking as well, just to decide what to focus on and to get some real-world examples.

It’s been at least a month since I’ve plugged Americium, so once again: GitHub - sageserpent-open/americium: Generation of test case data for Scala and Java, in the spirit of QuickCheck. When your test fails, it gives you a minimised failing test case and a way of reproducing the failure immediately. :grin:

Yes, there is definitely merit in getting simple tests for isolated bits of functionality to fail, then staring at the code and questioning your assumptions until you realize what’s gone wrong. Property-based testing with shrinking is good for this, but we know this already.

It’s all good Zen, but an unsung virtue of functional programming is that when debugging, the state of a failing program up the stack frames is all there in immutable amber (well, bar the use of tail-recursion, but you get the idea). I use contracts a fair bit, and when they do fail, it’s nice to be able to look at the overall state and see where I’ve gone wrong in my assumptions.

Likewise, it can be effective when working with third party code under time pressure to just explore what the code is doing with a debugger as an initial exploratory phase. Had to do that with sbt-pgp recently, I don’t think I would have known where to start otherwise. It’s different with your own creations, of course.

Just don’t get too much in the habit of debugging without analysing…

On the contracts front, if I’m writing immutable classes, I put an invariant check into the class that runs at construction time, if it’s cheap enough.

For pre- and post-conditions on methods, I start with an interface defined as a trait, write an implementation as an extending trait, then define a mix-in contracts trait with abstract overrides that surround calls to super with require and assert.

The tests instantiate the implementation trait with the contracts mixed-in and the production code instantiates the implementation trait by itself (I’m a bit sloppy with this sometimes, but that’s the ideal pattern).

If it’s just plain functions, I just inline the assertions into them and pay the price.

Scala Steward, and a spammed mail inbox!

EDIT: I forgot, the most important thing of all: https://grugbrain.dev.

2 Likes

I wound up writing a whole series of articles on the subject of testing Scala, a few years back, to describe the techniques I’ve come to prefer. I’ve applied these at several companies so far.

2 Likes

Be careful with both of those. Object creation isn’t free, but it’s not always a relevant expense – it’s not unusual for it to be a premature optimization. And while lazy evaluation is generally good, keep in mind that lazy vals per se are surprisingly expensive. (There’s a lot involved in making sure that they work reliably.)

To me, it’s all about avoiding dogma. Folks often wind up creating largely-duplicative layers of abstraction for its own sake. I usually find it to be worth spending extra time thinking about the separation of concerns, where I need more abstraction (or not), and ruthlessly avoid duplication.

1 Like

I’d like to just add one nuance to that:

The lazy val machinery has do extra work to be threadsafe. In Scala 3, if you know your lazy val doesn’t need thread safety, you can use @threadUnsafe lazy val to get better performance. See The @threadUnsafe annotation

2 Likes