In this article, we compare Tapir with endpoints4s. We highlight the differences by providing examples and explanations for the most common features you would like to have in your REST API. Both libraries only require you to describe the communication protocol in Scala. Once the communication protocol is written, you need to wire it with a specific HTTP Server (such as  Akka HTTP) and/or body parsing (e.g. Circe). At the end, the library produces clients, documentation, and servers for you with implementations of your choices. 

Read more

The Scalac Factor

Every company has its own culture. So do we. We really love to work together as a team. Scalac started as a remote-friendly company without managers, and we continue with those values in mind. Remote work is only growing inside the company, and even though the flat structure isn’t that flat anymore you can still call our CEO by name, and we’ve got more roles rather than complicated structures. Startup atmosphere – as they call it – but we prefer to call it the Scalac’s atmosphere because we believe that the fact that we work hard, have fun, and do the right thing is something unique and great about our company. 

Growing company

Why things changed at all? Because now we have exactly 124 people in our team while only 3 years ago there were only 70. So obviously, we had to reorganize how things are done. 

We have a core team, whose members are responsible for different business areas. Of course, at the top of our team, we have a CEO, but we also have technical leaders for different teams: Development –  Backend (mostly Scala), Frontend, QA, UX/UI designers, Business Development, Project Management, Human Resources, Finance, and Marketing. 

As I’ve mentioned before, we are far away from a corporate structure, which we want to avoid as long as possible. This is crucial, but the most valuable thing about  Scalac is the people –  working as one big team of functional, passionate people to keep our quality bar very high. 

5 things to join Scalac

Are you wondering how it is to be a Scalacer or thought about applying to Scalac? Here’s a quick guide to prepare you for joining our crew and to briefly show you what the core values we cultivate are.

1. Be sure that your code is functional

If so – that’s the first strong sign that we want you in our team! 

Be ready to learn

If your code is not functional… yet – this section could be for you. Our candidates are often strong and experienced Java, PHP, Python, etc. developers, willing to switch to Scala. Their code in the technical task is well-structured, has all the goodies of OOP. However, the code isn’t very functional or reactive. In this scenario, we could call it “Java/PHP/Python in Scala”. Too many variables, as well as a lack of the proper usage of techniques available in Scala, such as Tail Recursion.

Don’t get us wrong. This doesn’t mean that we will reject great guys like this at the beginning. We encourage them to work hard and change the way they code by providing solid feedback and learning sources. 

How to learn?

We are always happy to send them Scala Tips, which were prepared by our developers, and a list of good sources to study to help start properly with Scala. We also try to share our developers’ thoughts and opinions about a candidate’s code, so if you want to consult them during the recruitment process about anything – don’t hesitate to let us know – we will be happy to help. 

A good and proven method that works best for learning functional code is to read the famous red book “Functional Programming in Scala” or complete the Martin Odersky Course. They have several exercises that force you and your brain to switch to a functional way. It might be a bit painful at first, but when you manage to break through the wall of pain, you will be enlightened and delighted. And in our experience, you probably won’t want to go back and return to nonfunctional code. But let’s see – we await your opinion! d

2. Be sure that you are close to our values

Scalac at its core is a flat organization. We love to cooperate. We have three strong values at the center of our culture:

  • Work hard
  • Have fun
  • Do the right thing

Work hard

We work hard on our projects and put our whole hearts into making them real and work properly. 

Have fun

But after all this hard work, we like to spend some time together during our online informal meetings, talking about random stuff. Or during our integration trips when we can be together, doing new activities like off-roading, paintball, kitesurfing, and do even more talking, dancing, and signing up to the morning hours ;) 

Do the right thing

Doing the right thing is just as important as the previous two. We want to believe that everything we do – we do with good intentions and for a good purpose. Often we get involved in charity campaigns to help those in need. We choose good and responsible sources for our gifts or swag if you prefer. For our employees, the most important areas for ‘doing the right thing’ are the environment, children, and animals – so we try to help them whenever we can. 

If you’re one of us – we trust you

It may sound like a cliche but when you think about it, you realize that the answer to most of your questions is you. You know what is “the right thing”. So just do it. If it doesn’t work – you can make mistakes, we all make them. That’s the way we learn. The main reason is that we strongly believe in another of our values: “People at Scalac are very smart and responsible”. When you are smart and responsible, you are able to make decisions about your own tasks, take ownership, and face up to the consequences. You do it because you know it’s needed. This goes back to our core value “Do the right thing”.

Everything around coding has to be done by someone and in most cases, you are the one who decides what to focus on.  

3. Be ready to be an integral part of Scalac

The Handbook

We use the Scalac Handbook to show new employees how we work, who is who, how all the processes work. We want to be transparent and open to discussion at all times. In our Handbook, we have an unwritten chapter, where everyone can add something his or herself to improve our way of working. 

Scalac Times and Meetings

In our internal communications, we try to be as transparent as possible. We have our Scalac Times newspaper, which comes out cyclically every month and contains the most important information about what has been happening in our projects, teams, events, etc during the last month. In this newspaper, every team has its own space and can give any information it wants. We have also set up cyclical meetings between the whole team and our CEO, at which we can ask about anything connected to our company.  


A really important part of the way we function is knowledge sharing. We love to share our knowledge everywhere, all around the world in fact. We want to be an integral part of the Scala community and develop it together at different conferences, in our branches, or via taking part in open source contributions. Scalac organizes its own events such as Functional Tricity meetups, Scala Wave conferences, Scalac Summer Camps, and Pizza meetings, where we can also share our expert knowledge with each other. 

4. Be open to working remotely

Scalac is a remote-friendly company. This means that apart from the values we have already outlined, remote work is another core aspect of our company. How do we work remotely? 

How to work remotely

Our employees have already written great articles on this subject, you can find them here: 

In these texts, our Scalacers list several challenges of remote working and share tips on how to handle them from different perspectives. Please read them and think for a moment if it’s really for you. Maybe you would prefer to mix remote working with working from the office in Gdańsk or in coworking spaces. This type of work is challenging and requires a lot of self-organization and perseverance. But if it suits you – it can be a great, valuable experience! 

Despite the remote character of our work, as we mentioned before, we love to spend time together. We pay a lot of attention to our relationships and nurturing them. After work, we can talk on our #cafe or #wine channels or meet during company retreats. 

Watch this video to have a little sneak peek on how it looks like.

These days, after the coronavirus lockdown, the world and ways of working are changing and market research shows that remote work has become more popular than even just one year ago. And we’re quite lucky to have 6 years of experience in this matter. 

According to the ‘Remote Managers 2020 Report’, 87% of remote managers believe that remote work really is the future. 

5. Be ready to choose your own direction

It’s important to know where you are heading. Of course, your plans may change a year from now but we like to work with people who know what they want to invest their time in. We know that it’s hard to be good in every field. 

There are cases when somebody is a full-stack developer but being good at both ends of the software requires a tremendous amount of hard work. We really appreciate it and support it as much as we can. 

Scala hAkkers 

We are experts in the field of Scala functional programming and we want to work with the best Scala hAkkers in the world. 

It’s important to know where you want to be in the future and we as a company can help you develop your career path in various directions: 

  • Consultant – focused on cooperation between technology and business, explaining technical issues to the clients, proposing solutions, and ways to implement projects.
  • Team player or mentor supporter – focused on teamwork, always willing to help, share your knowledge, caring for the development of both yourself and the team.
  • Typical hacker – focused on the hard, complicated programming tasks to bring projects to an end with good quality. 
  • Any other path which you have a vision for, or maybe you have an idea you want to develop, something you’re able to show us and convince us it’s a great idea.   If so, yes! Let’s do it together! 

We like to work with people who are well aware of their choices and are fully committed to achieving their goals. We help them to do this by sponsoring their trips to the best conferences around the world, providing books, holding both internal and external training.

Join Scalac

On our Careers page you might like to take a look at us, our teams, how we work, what is important to us, which positions are open, and a lot more information which we hope will convince you to join us. 

As I mentioned before we do not have only Scala team, but it is our core – especially in the context of functional programming, so it was only to highlight where we’re coming from. We’re constantly changing and building new teams. 

So, are you ready to join Scalac? Do you agree with our core values? Is there anything you would do differently? If so, we’re happy and open to talk to you about it! 

Originally written in 01.2016 by Maciej Greń, updated in 09.2020 by Katarzyna Królikowska

We compared hours and hourly rates and the outcome might surprise you

Automation testing or manual testing? That is the question! Which one is better? This is something that gives many in the tester community sleepless nights. Especially during a project when we have to determine the testing strategy, even at its later stages. This is also, unfortunately, a question with no clear answer. Why? Because the reality is that these two ways of testing are inseparably connected with each other.

If you’re still wondering which is more profitable, effective, or better suited to your or your client’s project, let’s dive right in. Here we’ll be revealing the true value of automation vs manual testing, based on one of our clients’ applications – CloudAdmin.

Why CloudAdmin?

Together with Pawel Gieniec (CEO & Founder CloudAdmin), we are creating and developing a web application – CloudAdmin. Cloud Admin is a cost-optimization platform, which helps save thousands of dollars by eliminating hidden cloud instance buying costs.

The project turned out to be perfect for introducing an automation architecture for modules that are always tested during regression testing (the process of running older tests to ensure that new updates to a piece of software haven’t introduced or re-introduced previously eradicated bugs). Our goal is to save time, which we will then be able to spend on manually testing new functionalities. The application is developing dynamically, so we’ll be needing more and more time to check all of the fresh new features.

One of the core functionalities is logging. Nothing surprising, this is probably the module most often automated.

We decided to use automated testing with Python and pytest. 

And this is how it went:

We based our login tests on these 7 test cases below.

The first video is from PyCharm (the Python IDE). It shows how long it takes to run and finish all of the tests for the login module. As you can see, all of them passed. This means all of the cases have been tested correctly and have achieved the expected results, i.e.  the functionality works properly. 

Automated tests – this process took 1:39.

Now take a look at the video below. It shows how long it takes to carry out manual testing for the same test cases as for the automated tests. 

Manual tests – this process took 1:57.

How test automation reduces the time

At first, you might think “Hmm, this is no big deal. There’s only an 18-second difference in favor of automation.”. Okay, you’re right, but let’s think more in the long-term and globally.

First of all, it’s not just an 18-second gain. It’s an almost 2-minute yield for one run! This will ultimately give us more time to spend on the project. How? 

If you decide to implement automation architecture for sections that always needs to be covered by manual regression testing, you will gain all of this time for doing other things (manual testing for new features, application development, automation architecture development).

The login module is just a drop in the ocean. But let’s take a look further. Let’s compare working times by adding the next 3 modules: AWS Ri Planner, AWS RI Management, AWS Spend.

As you can see, the automation tests give us a 35% time yield relative to the manual tests (9:16 min vs 14:14 min). And again, this is a 14:14-minute gain, we have saved on regression tests – for only 4 modules.

How test automation increases productivity

As mentioned before, by dint of test automation, the testing team gains more time they can spend on doing other tasks. But this is not the only thing they get. Regression testing can be tiring. Imagine how tedious it would be to repeat the same action over and over again. This might sound trivial, but the time involved in repetitive, manual testing is wasted, especially over a longer course of time. With this in mind, think about how much more productive testers could be if they were able to devote more time to performance testing, security testing, or testing of new features. 

Moreover, testers have to quickly and continuously execute a lot of time-consuming tests simultaneously, just to be sure an application is performing as expected at all times.

How long will setting up an automation architecture take?

It’s rare that everyone on your team has  automation experience, so when it comes to resources, you will really have to gauge how much time it will take for your tool to make an impact. You will either need to invest in expanding your team’s automation knowledge or hire automation specialists.

Let’s go back to our CloudAdmin tests. 

Considering the  4 sections previously mentioned (Login, RI Planner, RI Management, Spend), our QA Engineer (the automation tests were written by one person) devoted 80 hours to:

  • choosing and configurating tools  – 16 hours
  • writing automation tests for every test case – 16 hours per module / 64 hours per 4 modules

It might seem like this is a long period of time, but don’t be fooled. Automation architecture is reusable. We can use it limitlessly.  Maintenance and development of automated test cases is an ongoing process. Unfortunately, there’s no guarantee that  spending eighty hours automating a set of tests means you won’t have to deal with them again. But it will still take definitely less time in general. Take a look at the graph below:


The turnaround time for automatic testing is definitively longer than for manual. However, the benefits of automation outweigh the time benchmark in terms of:

  • Reduction in execution time 
  • Reduction in effort 

These two metrics will provide an incredible boost towards higher efficiency and a better rate of reaction to potential bugs.

What about costs?

Last but not least, is something most crucial when establishing a contract with a client – costs. Test automation might be a catchy slogan during negotiations but it’s worth checking the hidden costs. You don’t want the client to think this is just another fashionable balloon, ready to burst at any time.

If you intend to adopt an automated testing process to meet the rising demand for bug-free releases and faster delivery cycles, it’s vital to assess whether the return on investment (ROI) is worth the changes. Before executing, or even considering building an automation strategy, you will want to calculate the net gain you will achieve from transitioning. Divide this by the net investment needed to transition (i.e., the tools and resources you use), and you will see your ROI for automated testing.


The maintenance cost of test automation is not usually trivial but if you keep enhancing the test automation framework, improve the application testability and overall testing processes, a high ROI is very much achievable.

To get the total cost, you will also want to take into account the hourly cost of the number of team members executing the tests.

Based on the average earnings for an Automation QA Engineer and a Manual QA Engineer (I rely on data from Glassdoor), we can make a labor cost comparison simulation based on the CloudAdmin case study (hours needed to perform each type of tests for this application).

As I mentioned before, it took 80 hours for our QA Engineer to develop and execute the automated tests for the 4 CloudAdmin sections. How many times might we manually test the same part of the app at the same time? 337 times (for the record: manual testing time -> total time from the module times table). So what’s better: 

  • spending $2,100 on performing  the same set of regression, manual tests 337 times


  • spending $2,700 on building an automation architecture – which once created is reusable?

We’ll let you answer that question ;) 

Final battle: Manual Testing vs Automation Testing

To wrap up, is an automation testing a cure-all for time-consuming manual testing? Not really. Manual testing will always be important. There are scenarios that will always require manually executed test cases. This means you will still have to create, run, and maintain the tests. But there are still a lot of situations when you can successfully implement automation. This can be a cost-effective method for regression testing of software products that have a long maintenance life.

With a balanced approach to the testing process and a smart and effective amalgamation of both automation and manual testing, you can spend more time focusing on building new features to enhance your product quality – ultimately giving yourself the opportunity to stay ahead and be more competitive in the market.

Check out other testing-related articles

Check out other business-related articles

Why do you need this Rest Assured API testing tutorial? In the world of testing – automation can be a real game-changer. That’s why it’s on everyone’s tongues, especially  “automated tests” in the context of GUI tests written in python/java with SeleniumWebDriver. Or possibly with the increasingly popular However, GUI tests sit right at the top of the basic pyramid when it comes to testing. 

Read more

The ultimate guide to do great on your next technical interview (Questions, Answers & more)

Common stereotypes show that in the IT industry, effective communication skills are not strong points in the profile of a typical programmer. They need only to have solid technical skills to help them accomplish specific tasks and to create particular products. But it turns out that nothing could be more wrong.

In the long run, when it comes to matching a new person to a team and making the right employment decisions, soft skills (communication skills, teamwork, creativity, etc.) are often more important than technical skills. 

According to specialists from a Linkedin team in the report “Global Talent Trends 2019”, bad hiring decisions and parting ways with employees are in 89% of cases caused by a lack of soft skills or deficiencies in both areas. Only 11% of partings were caused solely by problems with technical expertise.

In the opinion of Deloitte’s  “Soft skills for business success”, in 2030 as many as 2/3s of job offers on the market will require candidates to have specific soft skills.

Why we care about it at Scalac

At Scalac, our employees have the perfect combination of soft skills along with robust technical expertise, because as a company, we try to be as professional as possible when consulting with our clients. We always make sure we advise our clients, explain why any given solution is the best in our opinion and the pros, cons, opportunities, and threats of our proposals.

As a Scala development company, and experts in our field, we have to be able to express our opinions and knowledge professionally so our clients can be 100% sure that their cooperation with us was the right choice.

In this article, we would like to share this practical knowledge with you to help you nail any upcoming interview. 

After reading this article, you will know: 

  • How to prepare for a technical interview
  • How to show and share your knowledge
  • What a technical conversation looks like and why it is needed 
  • Where you can find answers to technical interview questions (reliable sources)
  • All communication tips & tricks 

Tech job interview – is it indispensable?  

Essential knowledge

The primary aim of a technical interview is to check the most essential knowledge of the candidate. The interview usually takes about an hour. Scalac is a remote-friendly company, so in our case, we most often meet online. We try to look more widely at the candidate’s competences and ask about things that they were not able to show during the implementation of their technical task. Sometimes, the candidate has made a mistake, used an inefficient way of using a given technology, or simply recombined it with a complicated solution. The technical interview is the right time for the candidate to clarify any important issues – by answering questions or initiating their own clarification of the case in question. Interestingly, our experience shows that sometimes a candidate hasn’t done the technical recruitment task completely independently. During the interview, we can easily verify this and avoid any misunderstandings on both sides.

Point of view

Based on our experience, we like to regard this stage of the process as a joint meeting of experts and practitioners in our field. After all, we are all programming enthusiasts in the conversation, regardless of the side, we are on – interviewer or interviewee. The interview is also the time to ask about the perspectives of the other technical people in the company, know their points of view and experiences. Why are they working here? How are they working? What projects are they involved in? What technologies do they use? What tools do they use? It’s best to ask about these kinds of issues at the source, and the interview is an ideal opportunity.

Soft skills

Of course, this is also an excellent opportunity to check the candidate’s soft skills, such as communication, proactive approach, problem-solving, searching for solutions, flexibility, and much more. Our recruiters often say that they are looking for people who can solve problems and the challenges they face. This is the most important to them. The data provided by Linkedin also confirms this – the most desirable soft feature on the labor market is creativity, which, contrary to popular belief, is not only reserved for the creative professions. Recently, this has evolved into a feature that allows you to solve problems in an original, unconventional, and at the same time effective way. Having this kind of competence turns out to be crucial in the face of very dynamic tasks and a rapidly changing reality.

Based on the Linkedin data, the most appreciated – and therefore desirable – soft features on the labor market are creativity, persuasion, collaboration, adaptability, and time management.

In the era of technical development and automation, the demand for social skills will continue to increase until 2030, because it is a feature whose machines, even with using the artificial intelligence, are not able to take over and replace so quickly (McKinsey Institute, “Skills shift automation and the future of the workforce”)

How to prepare for a technical interview

Technical competence is practiced every day and improved continuously because programmers use their practical knowledge when doing projects and programming new functionalities as part of their professional duties. However, sharing knowledge with someone else, – especially not in any specific practical case but also theoretically – can be more complicated and require more communication and persuasive skills than it may seem.

What questions are asked in a technical interview? Scala technical interview questions – (Scala Programming Language interview)

As Scalac is a Scala development company the leading technology we use is, not surprisingly, Scala, which is why most of our technical recruitment is for Scala Developers. Below, we’ve also collected some valuable content on typical Scala technical questions.

There are plenty of sources on the web where you can find sample questions, so not wanting to discover America again, we have decided to combine our own questions with the best ones on the web, revised by our developers. Now you have all the best questions (with answers) in one place! Scala development company tested.

How can I pass a technical interview? 6 Tips from our developers

Some time ago, our developers prepared a few rules to send to candidates before a technical interview to prepare them for what it will contain. So it  will not be  too stressful, below are some tips that you should consider if you want to be well prepared for the main points of an interview

  • Before the technical interview, make sure you have your solution from the previous stage to hand, so you can open it if needed. Or just make sure you remember your solution. Sometimes technical recruiters will want to ask you about something you did in your task. 
  • Some concepts might be easier to explain with code than with words, so make sure that any app for presenting your code works fine on your device. It could be a simple “online text file” so you can share what you have in mind. This practical way of explaining is sometimes easier and shows your practical knowledge and experience off better.
  • Sometimes, it could happen that a technical recruiter will ask you to do some “live-coding” during the meeting. This can be helpful to convey meaning. The idea is not to write compiling the code. 
  • Even if you have a lot of experience and knowledge when describing a topic, it is better to come up with an answer that covers the main points as briefly as possible. If you explain something for a long time and it turns out that this is not the best question for you, you will have less time for another question which could be better and more comfortable for you.  
  • It’s always good to spend some time refreshing your knowledge because sometimes it’s hard to recall how something works if you used it a year or more ago.
  • From a practical point of view, before the call, remember to check whether your Internet connection, microphone, speakers, and camera are working correctly so you can talk without any disruption and delay during the meeting. To feel comfortable, you can always ask your recruiter about any “dress code” for the interview – not to be overdressed or too informal.

6 pieces of advice on communication from a professional interviewer 

From a typical communication point of view, it is worth remembering the following issues so you feel by the end of the interview that both sides have the right information necessary to make further decisions regarding the recruitment process.

  • If the interview is online – everybody knows that sometimes the connection quality can be lacking, freeze, or something – so do not hesitate to ask for clarifications or to rephrase anything – it’s perfectly fine!
  • If anything wasn’t clear  – feel free to ask for clarification, explanation, or to repeat it – this is completely natural, -we should always strive for the best understanding from both sides.
  • Remember that the technical person is there to also answer your own questions about the company you are applying to.  Don’t be afraid to ask – if everything goes well you will be working together in the same team anyway. 
  • Remember one basic rule – “I don’t know” is sometimes a good answer.  It shows your maturity and openness – so do not hesitate to say it if necessary It is better to admit you don’t know than to try to figure out a response when you actually don’t have any idea about something.   
  • Make sure you know how much time you will need for the technical interview and make some time in case it needs to be prolonged. You can always ask the recruiter about the probable duration. 
  • It’s perfectly fine to ask your recruiter what the interview will be about and what it will consist of. Remember, you can tell the recruiter about your previous experience to let him be aware of your strengths. You can also mention which topics you feel the most comfortable talking about and which specific topics you feel you are expert enough to show off your best side.

How to answer tech questions to show them you know what you’re talking about 

It sometimes happens that technical teams in IT companies may have direct contact with clients, as well as with their technical and business units at the stage of determining a project’s requirements. Scalac is a Scala development company and since 2013 when the company was founded – staying in touch with the client side is an integral part of our work. That is why sharing knowledge is essential, and we make sure to this when talking to the candidates.

Using The Feynman Technique to boost your communications skills

When it comes to preparing for the communication aspect of an interview, and showing off your knowledge, a good example to follow is the theory of Richard Feynman, an American Nobel laureate in physics. This technique

“involves using a simple analogy or metaphor to explain any issue. Although its main purpose is to identify questions that you can’t answer, thanks to which you find gaps in your knowledge, and you can get to know what you are learning.”

Feynman suggests that a perfect method to prepare and, assimilate your knowledge, and to realize any possible deficiencies, is to clarify an issue in simple words to reach the recipient, imagining it is a 5-year-old child. This simple way of communication is excellent in interviews especially on complex, technical topics, in which, at the same time, we have to demonstrate our understanding.

By explaining the issues,  both in-depth and in simple language, we can show off our practical knowledge and the fact that we know what we are talking about. At the same time, do not try to hide certain areas of ignorance behind a veil of complicated, theoretical expressions and words. That’s why it is worth relying on examples and, if possible, share completed case studies from previous personal experience.

Isn’t simple language too simple for a tech talk? 

Technical knowledge should of course be at an advanced level in the case of a typical professional interview, especially for regular and senior positions. But even in these cases,  sticking to simple, direct sharing of your knowledge is important. Not least precisely because it happens sometimes that business clients are not technical people. Explaining to them project assumptions, technical offers, main challenges, risks, and normal activities in a programming project may require more of a practical, pictorial, and transparent approach to prove our expertise and knowledge of a given issue. 

What you’ve learned – you know.

Remember that every experience you have had can be a lesson which you can always draw conclusions from and make good practices for the future. That’s why it’s worth bearing, in mind that there are no stupid questions.

As a well-known Polish proverb says “who asks will not go astray” – you can confidently ask questions during the recruitment process. Ask for any clarification if you do not understand something, or simply ask for a reminder of any issue already raised to ensure everything is transparent and understandable. Go ahead and feel free to ask about any typically technical aspects you can talk about with a technical recruiter, such as “what technologies are most often used in your projects?”, “What is the scope of the work in the project?”.

During the discovery interview with the HR team, any questions regarding work culture and company operations are also welcome. A recruiter who is interested in real dialogue with a candidate will be happy to answer any issues.

What can you learn from feedback? 

It is also vital to make sure you understand well any technical feedback provided – regardless of whether it may cause a further stage or delay the end of the process. You can always get valuable information and suggestions in feedback, which you could use to improve your skills and be able to present yourself better in the next stages of the recruitment process. We should always get the best out of feedback and regard it as an opportunity for development in areas that may require further work, and which will allow us to spread our wings in the future.

To sum up

After taking into account the above points, getting acquainted with the research we have quoted, and observing the labor market, we can confidently say that a technical interview is necessary in the recruitment process. That is true from ’ the point of view of both sides of the recruitment process

From the employer’s perspective…

considering the predicted increase in the importance of soft skills in the labor market, it is right that companies should develop their recruitment processes in this direction to make the right recruitment decisions.

From the candidate’s point of view…

the technical interview allows you to present yourself at your best, to explain your thinking and defend your arguments. It is also an opportunity to obtain additional, valuable, and technical information from other developers, already working in the company you are applying to.

It is worth noting that currently, soft skills are gaining value. We, therefore, hope that some of the above tips showing how to prepare well for a technical interview will prove useful will give you an advantage in the labor market and will help reduce the stress that often accompanies the recruitment processes.

Thank you for reading the article!

I hope that after reading this article there will be no doubt that technical interviews make sense and you will need to prepare for them not only in terms of typical programming knowledge, but also in terms of communication skills.

I can do nothing more than to keep my fingers crossed for the success of each of your future recruitment interviews and hope that we will meet at some stage of the recruitment process at Scalac ;)

What are your experiences regarding technical interviews in the companies you have applied for? Do you think they were useful and valuable? Do you now understand how necessary soft skills are in your job?

See also:

Scale fast with Scalac – Scala development company ready to solve all your challenges.

As a tester I often get to test functionalities in which the approach to the subject plays a key role. In a previous article, I have already written about how to approach manual testing on mobile devices generally. But here I would like to talk about one of the most interesting functionalities I have had an opportunity to test. Namely the testing of advertisements in different time zones. 

The assumption of the functionality was that a banner placed on an advertisement on a given day and time could be displayed anywhere in the world, making it possible to promote movie premieres or sales promotions. The creator of the advertisement would be able to set the day and time strictly in the “User time zone” but would also be able to enter the time zone manually. 

Why 590 timezones?

Initially, the programmers wanted to use UTC to divide it into and cover 24 time zones. Then all the creator would have to do, is simply enter the country and the program would automatically add the time zone.

However, after initial tests, it turned out that this solution has one main disadvantage, namely, there is no provision for summer and wintertime, which change in virtually every country.

After consultations, they decided that the best solution would be to use the moment.js library, which is based on times in Wikipedia. With this solution, the user could enter any destination region into the field regardless of the current time. The most up-to-date solution was divided into 590 regions (TZ database).

What devices did we have to check? 

This functionality was supposed to work on both desktop and Android devices from 5.1.1 version to the newest (it’s now 10.0) and for iOS from 9.3.5 to the newest (13). There were 13 versions of both systems to cover and 590 possibilities to check. I and a second tester had a maximum of just two days to plan and conduct the tests. So we had to come up with a manual testing approach that would give us sufficient test coverage but in this very limited time. 

Manual testing 590 timezones devices

How to approach manual testing when you have 590 Time zones and 2 days

Finding common ground

We started by listing all 590 zones and finding common features, i.e. the same UTC. For example, times in Europe / Astrakhan, Europe / Samara, Europe / Saratov, Asia / Dubai, Asia / Muscat, Asia / Tbilisi, Asia / Yerevan all have UTC +04: 00 in summer and wintertime so there would only have to be one test for all of these regions on all of the devices. After these merges, according to the UTC, there were 54 zones to check. However, 54 zones is still a lot, so we had to find another elimination factor. 


The next step was to take all of the 54 available zones and check which ones have high priority, which medium and which did not need to be tested at all.

High importance in our case meant that the timezone was important from a business point of view. For example, the zone in which a large part of Europe is located, i.e. +01: 00 in winter and +02: 00 in summer. Medium priority was given to those that were less significant for business or less used, such as the zones in which the Azores or Egypt are located in.  But also parts of Australia, for example,  because Australia has 5 different time zones and some were more important than others. Zones on smaller islands, practically uninhabited, such as the zone in which Fakaofo is located, i.e. an archipelago from a group of coral islands with just 265 inhabitants, were also considered not important from a business point of view.

Testing timezones
Testing cases for timezones - manually tested

After counting and analyzing the highest priority zones, we managed to reduce it to 25 must-haves and 11 nice-to-haves that would also be checked eventually if time allowed. You should also remember that some of these zones would need to be divided into summer and wintertime as well. So let’s keep that in mind. 

How to change time zones on devices. Preparing for the manual testing

Each of the devices being tested had to have a different zone with each test.  The largest available city was selected from each time zone, and the times were changed based on that. 

Changing the time zone on a specific device is not difficult, you just need to know where to look. I will show how to do it on macOS, iPhone, and Android devices (in this example Sony Xperia).

First device: MacBook

To change the time zone and Date & Time we have to press the date in the upper right-hand corner. Then press “Open Date & Time Preferences” and then go through 4 steps:

1. Unlock the possibility of changes

2. Turn off automatic date updating

3. After going to the Time Zone tab, turn off the ability to automatically load locations

4. Choose the location around the city or by clicking on the map

Second device: iPhone

On the iPhone we have to go to Settings -> General -> Date & Time and disable the option “Set Automatically”. Now we can change the time zone by entering the city name and change the date and time. 

It is important that when you want to test the application on iOS, you need to first download the applications on the phone (from Xcode). Then change the date/time zone because in the reverse order you will not be able to upload the application in the future date (on Android there is no such dependence).

Third device: Sony Xperia

On a Sony Xperia phone (there are no major differences on other Android devices) we go to Settings. Then we go to Date & Time and disable the automatic date and time zone settings.

Test cases

After preparing the test environment and time zones, we could then start on the test cases. After going through the simplest cases of playing advertisements at a given date and time, we also had to check whether everything was working properly in any given summer and winter time. 

A good way to do this was to check the wintertime by setting the date in December or November. Because in October the time changes around the world, but it is worth giving yourself a safety buffer. And then test summertime at the end of May. 

It’s also worth checking testing zones at the end of the day.  This is to check the disappearance of the banner at 23:59 and its appearance at 00:01 the next day. The disappearance of the banner and appearance in two weeks, only on certain days or vice versa was also checked.

Some of the more interesting issues that came up during the whole manual testing process were:

  •  when we changed the orientation, the banner did not appear.
  • Another thing was that when a given advertisement was fired by the user, the hour was not read in real-time. For example the banner was to appear at 17:00 and the user started displaying the advertisement at 16:59, the banner did not appear.


Thanks for reading the whole article! As (I hope) I have shown you, manual testing of time zones can be quite simple and interesting. You just have to make sure you clarify the most appropriate approach from the beginning. 

In this case, after the feature went into production, we didn’t have to test it any further. If, of course, the tests were supposed to be repeated regularly, it would be necessary to devise a strategy for writing automated tests. A little spoiler alert… We were able to devise the test approach and do the testing itself in just two days! It was a lot of hard work, but the outcomes were totally worth it. 

See also

Kubernetes is not a novelty anymore – but somehow, its potential is still far from being exploited. It started being adopted on a massive scale only recently, and is now in production at 78% of enterprises surveyed by The Cloud Native Computing Foundation (CNCF). Before that, however, Kubernetes didn’t really get the attention it deserves.   

If you’re still sceptical about Kubernetes for some reason, it might be the right time to change your mind and join these change-makers, instead of struggling to understand what this platform is all about. To make it easier for you: Here’s what Kubernetes is, along with the reasons why the tech world is going crazy about it (and why you should, too). 

Read more

JVM creators designed it with automatic memory management in mind, which means programmers don’t need to worry about memory allocation and memory. Unused objects can be released automatically in a transparent way, which is really convenient, especially when you’re new to JVM. But even in general, there’s less code to write and it’s less error-prone than the traditional approach which requires you to do everything manually. 

Read more

At Scalac we always try to use appropriate technology to meet the requirements given by the business. We’re not afraid to change them once we realize they’re not fit for purpose. By making these changes we learn, we grow, we evolve. In this article, we want to share what we learned during the migration from Apache Spark + Apache Cassandra into Apache Druid.


Why did we decide to do it?

What you can expect after the change?

How did this change reduce costs and simplify our solutions and why did we fail to deliver expectations with the first architecture?

Let’s find out together!


The Law of Expectation

Defining expectations is a crucial part of every architecture, application, module or feature development. The expectations that were presented to us were as follows.


We need to process and deliver data to the UI in near real-time.

Since our project is based on Blockchain, we want to react to all of the changes as soon as they are made. This can be achieved only in real-time architecture. This is one of the most important decisions to be taken at the beginning of the project because it limits the range of technologies we can take with us.


We need to be able to display aggregated data with different time granularities.

To improve performance and readability on the UI, we want to return aggregated data. This should make displaying the data more responsive and lightweight. The solution should be flexible enough to allow us to configure multiple time-ranges because they are not yet defined and may depend on the performance or layer findings.


We need to introduce a retention policy.

Since the data after a specific time are meaningless for us, we do not want to keep them forever and we want to discard them after 48 hours. 

We want to deploy our solution on the cloud.

The cloud is the way-to-go for most companies, especially at the beginning of a project. It allows us to scale at any time and pay for the resources we are using. Often just a small number of developers start a project, so there is no time to focus on machine management. The entire focus should be on something that brings value to the business.


We want to be able to display data about wallets and transactions.

Since this project is all about Blockchain, we want to display some information on different levels. The most detailed is at the transaction level, where you can see data exchange and payments between different buyers and sellers. Wallet groups multiply transactions for the same buyer or seller. This allows us to observe the changes in the Blockchain World.


We want to display TOP N for specific dimensions.

In order to display wallets that have the biggest participation in day-by-day payments in Blockchain, we need to be able to create tables that will contain wallets sorted by the amounts of data changes or payments during a certain time.


Let’s take Spark along with us!

Since the road for creating real-time architecture was a bit dark for us at the time, we decided to take Apache Spark along with us! Nowadays, Spark is advertised as a tool that can do everything with data. We thought it would work for us… but we couldn’t see what was coming. It was dark, remember?


Spark Cassandra Architecture


Initially, we came up with the above architecture. There are a couple of modules that are important, but they are not crucial for further discussion.

  1. Collector – This was responsible for gathering the data from different APIs such as Tezos and Ethereum, unifying the format between these two and pushing these changes to Apache Kafka in the form of JSON. Additionally, since we were dealing with streaming, we needed to know where we were, so we would be able to resume from a specific place so not to read the same data twice. That’s why Collector saves the number of processed blocks to the database – Apache Cassandra in our case. We had it in place, we simply used it for a slightly different purpose.
  2. API – This was a regular API, simply exposing a couple of resources to return results from Cassandra to our UI. It is important to know that it was not only the window for the data in Cassandra, in this API, we could also create users, roles and write them back to the database.



It’s always important to ask yourself why you are choosing these technologies, what you want them to do for you. Here are the motivations for the tools that we decided to use:


Why did we decide to use Apache Kafka?

At the very beginning, we wanted to create a buffer in case Spark would be too slow to process these data, to not lose them. Additionally, we wanted to separate two different tools from each other. Direct integration between Data Source and Spark might be hard and deployment of any of these components could break the entire pipeline. With Apache Kafka between them, it’s unnoticeable. Since the entire solution was designed to work with Blockchain, we knew that we couldn’t lose any transactions. Thus Kafka was a natural choice in this place. Additionally, to fulfill also the requirement for different time-series to be handled. We knew that we could read the same data, from the same topic by multiple consumers. Apache Kafka is very popular, with good performance and documentation.


Why did we decide to use Spark Streaming?

Spark Streaming was a good choice in our opinion due to the fact that it has good integration with Apache Kafka, it supports Streaming (in the form of micro-batches), it has good integration with Apache Cassandra and aggregating by a specific window is easy. Spark Streaming stores the results of the computation in the internal state, so we could configure it to precompute TOP N wallets by the transactions there. Scaling in Spark is not a problem. Spark supports multiple modes for emitting events (OutputMode) and for writing operations (SaveMode). We thought  that OutputMode.Append and SaveMode.Append would be a good option for us. 


Why did we decide to use Apache Cassandra?

We decided to use Apache Cassandra mostly due to fast reads and a good integration with Spark. Additionally, it’s quite easy to scale Cassandra as well. Cassandra is also good if we want to store time-series data. SQL should not be too hard for this , in our case data should be already prepared. Cassandra requires more on the writing side than reading.


1. First bump. Stream Handling in Spark.

At the beginning, we didn’t realise that one of the problems would be that Spark can read/write only from/to one stream at a time. This meant that each time we wanted to add another time dimension we would need to spawn another Spark Job. This problem had an impact on the code because it had to be generic enough to handle multiple dimensions by its configuration. Adding more and more Jobs always increases the complexity of the entire solution.


2. Second bump. Cluster requires more and more resources.

Along with the generalization of Spark Job, we had to change our infrastructure. We were using Kubernetes for this, but the problem was that with Spark we could not assign less than 1 core even though Kubernetes allows us to assign 0.5 CPU. This led to poor core utilization of Kubernetes Cluster. We were forced multiple times to increase the resources within the Cluster to run new components. At some point, we were using almost 49 cores!


3. Third bump. Spark’s OutputMode.

We didn’t have to wait too long for the first problems. That was actually a good thing! We realized that OutputMode.Append was not going to work for us. You may ask yourself: why? Glad you asked! We’re happy to explain.

We need to be able to ask Apache Cassandra for the TOP N results. In our case, it was the TOP 10 wallets by the number of transactions made by them.


Firstly, let’s think about the table structure:


   wallet_id       TEXT,

   window          TEXT,

   window_ts       TIMESTAMP,

   amount          DOUBLE,

   PRIMARY KEY ((wallet_id, window, window_ts), amount)


Secondly, let’s think about the query:

SELECT * FROM wallets WHERE window = '10m’ AND window_ts = '2020-01-01 10:00:00.000' LIMIT 10;

Due to the definition of primary key + WITH CLUSTERING ORDER BY, the data will already be sorted by amount.


Thirdly, let’s insert some data into it:

INSERT INTO wallets (wallet_id, window, window_ts, amount) VALUES ('WALLET_A', '10m', '2020-01-01 10:00:00.000', 1000);

INSERT INTO wallets (wallet_id, window, window_ts, amount) VALUES ('WALLET_B', '10m', '2020-01-01 10:00:00.000', 1020);

INSERT INTO wallets (wallet_id, window, window_ts, amount) VALUES ('WALLET_C', '10m', '2020-01-01 10:00:00.000', 1050);

INSERT INTO wallets (wallet_id, window, window_ts, amount) VALUES ('WALLET_D', '10m', '2020-01-01 10:00:00.000', 800);

INSERT INTO wallets (wallet_id, window, window_ts, amount) VALUES ('WALLET_B', '1m', '2020-01-01 10:00:00.000', 500);

Now imagine that Spark is grouping the data for a 10-minute window and it’s going to emit an event once the internal state changes. Only the change itself will be emitted. 

The new transaction is coming for the wallet: WALLET_A, time: 2020-01-01 10:03:00.000 and amount: 500.

After that, Spark is going to emit an event and ask Cassandra to insert:

INSERT INTO wallets (wallet_id, window, window_ts, amount) VALUES ('WALLET_A', '10m', '2020-01-01 10:00:00.000', 1500);

Normally, what we would expect is that the first row from the data we inserted would be updated, but it wasn’t because the ‘amount’ which changed, is part of the primary key. Why is this important? Because with insert operation instead of update we will have multiple rows for the same wallet. We cannot change the primary key, because we want to sort the data by the amount and so the circle closes. 


Can OutputMode.Complete push us forward?

Since the road with OutputMode.Append was closed for us, we decided to change the output mode to Complete. As the documentation says, the entire state from Spark will be periodically sent to Cassandra. This would, of course, put more pressure on Cassandra, but at the same time, we would ensure that we would insert no duplicates into the database. In the beginning, everything worked well, but after some time, we noticed that with each insert, Spark was putting in more and more rows. 


Let’s turn back. Spark + Cassandra does not meet our expectations.

Why did we decide to turn back and change the current architecture? There were two main reasons.

Firstly, we investigated why Spark was adding only new rows and it turned out that Watermark which is configured in Spark does not discard the data when they should be. This means that Spark would collect the data indefinitely in its internal state. Obviously, this was something that we could not live with and that was one of the most important reasons to change Spark + Cassandra. Spark could not produce the results for one of the most important queries, so we had to turn back.

Secondly, we noticed that maintenance was getting more complicated with each time-series dimension and the configuration for resources for Spark Cluster was not flexible enough for us. This led to too high costs for the entire solution. 


The New Beginning, with Apache Druid.

You may ask yourself why we decided to choose Apache Druid? As it turned out, when you are deep in the forest, a Druid is the only creature that can navigate you through it.

Apache Druid Architecture


At first glance the change is not that significant, but when you read the motivation, you will see how much good this change did for us.


  1. Firstly, Apache Druid allows us to explore the data, transform them and filter what we do not want to store. It contains databases as well, so we can configure the data retention policy and we can use SQL to query against collected data. This means that Druid provides a complete solution for processing and storing the data, whereas in the previous architecture we had been using Apache Spark + Apache Cassandra. By using this tool, we could delete two components from our initial architecture and replace it with a single one, which reduced the complexity of the entire solution. Additionally, it gave us a single place to configure it all. As a trade-off, this solution is not as flexible as Apache Spark + ApacheCassandra may be. A lack of flexibility can be noticed mostly at the data transformation + data filtering more than in the storage. Even though Druid provides simple mechanisms to do this, it’s not going to be as flexible as writing code. Since we mostly aggregate by time, this is a price we can pay.
  2. Secondly, the configuration of Apache Druid on top of Kubernetes is much better. Druid contains multiple separated applications that handle different parts of the processing. This allows us to configure resources at multiple levels, so we can use the resources we have more efficiently.
  3. Thirdly, in Apache Druid, we can use the same data source multiple times to create different tables. Since we used Apache Druid mostly to fulfill the TOP N query, we re-used the same topic in Kafka twice. With and without rollup. Since the smallest time granularity that we have is 1 minute, this is how rollup was configured. This speeds up the query causes that the data are already pre-aggregated. This may have a huge impact when the rollup ratio is high. In cases when it’s pretty low, we should disable it.
  4. Fourthly, since there is a UI in Apache Druid, you do not need to write any code to make it work. This is different from what we had to do in Apache Spark. At the end of the configuration process in Apache Druid, we got JSON, which can be persisted and versioned somewhere in an external repository. Note that some of the configurations (such as retention policies) are versioned by Apache Druid automatically.
  5. Fifthly, Apache Cassandra is not the best database to store data about users, permissions, mostly website management data. This is due to the fact that Cassandra is made for fast reads, and operations of this kind can have a negative performance impact. To avoid this, these operations are not supported by design. This is why relational databases are better for this kind of data and we decided to introduce PostgreSQL for it.

What did Druid tell us?

Most likely, you don’t want to run Druid in local mode.

By default, Druid is configured to run only one task at a time (in local mode). This is problematic, when you have more data sources to be processed, essentially, creating more than one table in Druid. We had to change druid.indexer.runner.type to “remote” to make it work as expected. When you have only one task running and multiple pending, make sure that you have this option changed to ‘remote’. You can read about that here.


The historical Process needs to see segments.

We encountered an issue with the Historical Process, which did not notice the segments (files) created by Middle Manager. This happened because we did not configure proper storage for the segments. Druid works in a way that the segments need to be accessible via Historical Service and Middle Manager because only the ownership is transferred from Middle Manager to Historical Service. That is why we decided to use storage from the Google Cloud Platform. You can read more about deep storage here.


What resources did we burn?



Apache Spark + Apache Cassandra

Apache Druid


49 cores

6 cores







As you can see, we managed to drastically decrease the number of cores used to run our infrastructure as well as memory and storage. The biggest reason why this was possible was that we had multiple Spark Jobs running to do the same task that the single Apache Druid can do. The resources needed for the initial architecture had to be multiplied by the number of jobs running, which in our case was 24.



To sum up what we learned during this venture. 

Apache Spark is not a silver bullet.

Spark allows us to do many, many things with the data. Beginning with data reading from many formats, cleaning data, transforming them, aggregating them and finally writing to multiple formats. It has modules for machine learning and graph processing. But Spark does not solve all the problems! As you can see, in our case it was impossible to produce results that could fulfill the requirements of aTOP N query. 

Additionally, you also need to keep in mind that the architecture of your code, the generalization, and the complexity of it will significantly affect the resources needed to process the data. Due to the fact that we had 24 jobs to be executed, we needed a huge amount of resources to make it work.


Apache Spark is made for big-data.

When you have many gigabytes of data to be processed, you could consider  Spark. One rule says that you should consider using Spark when the biggest machine does not allow you to process the data on it. Keep in mind that processing data on a single machine will be always better than doing it on multiple machines (I’m not saying faster, but sometimes this can be also seen). Nowadays everyone wants to use Spark, even if there are just a couple of megabytes of data. Bear in mind, choosing the right tool for the right job is half of the success.


There is always a tradeoff.

There are no silver bullets in the Software Development World, Spark is not one of them as you have seen above. There is always a tradeoff. 

You can drive faster, but you will burn more fuel. You can write your code faster, but the quality will be poorer. You can be more flexible, generalize your code better, but it will become harder to read, extend, debug. Finally, you can pick a database that allows you to read fast, but you will need to work more on the structure of this database and writing into it will be slower.

You need to find the right balance and see if you can cope with the drawbacks that a specific technology brings along with its positives.


Use the right tool for the right job.

Good research is the key to making sure that the appropriate technology has been chosen. You need to remember that sometimes it is better to reserve a bit more time for investigation, writing a Proof of Concept before diving hard into one or other technologies. You will definitely end up wasting time having to rewrite your solution. Obviously, this depends on where you spot the first obstacles. In our example, you can see that firstly our infrastructure was quite complicated and required a lot of resources. We managed to change it dramatically by using a better tool which fitted the purpose better.


How it looks now. A working solution.

Even though this article sounds a bit theoretical, the project we took this experience from is real and still in progress. 

The Analytical Dashboard that we created to visualize the data from different blockchains are based on React with Hooks, Redux, Saga on the front side, and Node.js, Apache Druid, Apache Kafka on the back-end. 

On the day of writing this article, we are planning to re-write our data source component from Node.js to Scala + ZIO. So you can expect some updates, either in the form of a blog post or an open-source project at GitHub. We have a couple of Blockchains integrated, and we are planning to add more. We are also planning to make the API public so that everyone can experiment and learn from it just as we did.


You can check our Analytical Dashboard out right now at

Check out also our websites on:

Scale fast with Scalac – Scala development company ready to solve all your challenges.

Writing concurrent data structures using traditional tools – just like everything else under java.util.concurrent – is generally very complicated. You need to really think about what you are doing to avoid typical concurrency issues, such as deadlocks and race conditions.  And let’s be honest, thinking about all the possible scenarios that could arise is not just hard, but also sometimes infeasible.

So, in this article, we are going to see how ZIO STM can make our lives a lot easier when it comes to writing concurrent data structures – such as a concurrent LRU Cache – in a completely lock-free fashion that is a lot simpler to reason about.

Requirements of an LRU Cache

A cache is a structure that stores data (which might be the result of an earlier computation or obtained from external sources such as databases) so that future requests for this data can be served faster.

Now, a Least Recently Used (LRU) Cache must fulfill these requirements:

  • Fixed capacity: This is for limiting memory usage.
  • Fast access: Insert and lookup operations should be executed in O(1) time.
  • An efficient eviction algorithm: The idea is that, when the cache capacity is reached and a new entry needs to be inserted into the cache, the Least Recently Used entry gets replaced.

More concretely, an LRU cache should support these two operations:

  • get(key): Get the value of a given key if it exists in the cache, otherwise return an error.
  • put(key, value): Put the given (key, value) into the cache. When the cache reaches its capacity, it should evict the Least Recently Used entry before inserting a new one.

So, for implementing an efficient LRU Cache (meaning get and put operations are executed in O(1) time), we could use two data structures:

  • Hash Map: containing (key, value) pairs.
  • Doubly linked list: which will contain the history of referenced keys. The Most Recently Used key will be at the start of the list, and the Least Recently Used one will be at the end.

In the following image, we can see an example of the status of a LRU Cache (with capacity of 4) at a given moment:

Example of the status of a LRU Cache

So, the history of referenced keys (1, 3, 2 and 4) shows that Key 1 is the Most Recently Used one (because it’s at the start of the list), and that Key 4 is the Least Recently Used one (because it’s at the end of the list). So, if a new item needs to be stored into the cache, Item 4 would have to be replaced.

Quick introduction to ZIO

According to the ZIO documentation page, ZIO is a library for “Type-safe, composable asynchronous and concurrent programming for Scala”. This means ZIO allows us to build applications that are:

  • Highly composable: Because ZIO is based on functional programming principles, such as using pure functions and immutable values, it allows us to easily compose solutions to complex problems from simple building blocks.
  • 100% asynchronous and non-blocking.
  • Highly performant and concurrent: ZIO implements Fiber-based concurrency, and by the way, you can read more about ZIO Fibers in this really nice article written by Mateusz Sokół here.
  • Type-safe: ZIO leverages the Scala Type System so it can catch more bugs at compile time.

The most important data type in ZIO (and also the basic building block of ZIO applications) is also called ZIO:

ZIO[-R, +E, +A]

The ZIO data type is called a functional effect, which means it is a lazy, immutable value that contains a description of a series of interactions with the outside world (database interactions, calling external APIs, etc.). A nice mental model of the ZIO data type is the following:

R => Either[E, A]

This means that a ZIO effect needs an environment of type R to run (the environment could be anything: a database connection, a REST client, a configuration object, etc.), and it can either fail with an error of type E or succeed with a value of type A.

Finally, it’s worth mentioning that ZIO provides some type aliases for the ZIO effect type which are very useful in representing some common use cases:

  • Task[+A] = ZIO[Any, Throwable, A]: This means a Task[A]is a ZIO effect that:
    • Doesn’t require an environment to run (that’s why the R type is replaced by Any, meaning the effect will run no matter what we provide to it as environment)
    • Can fail with a Throwable
    • Can succeed with an A
  • UIO[+A] = ZIO[Any, Nothing, A]: This means a UIO[A] is a ZIO effect that:
    • Doesn’t require an environment to run.
    • Can’t fail
    • Can succeed with an A
  • RIO[-R, +A] = ZIO[R, Throwable, A]: This means a RIO[R, A] is a ZIO effect that:
    • Requires an environment R to run
    • Can fail with a Throwable
    • Can succeed with an A
  • IO[+E, +A] = ZIO[Any, E, A]: This means a IO[E, A] is a ZIO effect that:
    • Doesn’t require an environment to run.
    • Can fail with an E
    • Can succeed with an A
  • URIO[-R, +A] = ZIO[R, Nothing, A]: This means a URIO[R, A] is a ZIO effect that:
    • Requires an environment R to run
    • Can’t fail
    • Can succeed with an A

Implementing the LRU Cache with ZIO Ref

First, we need to add some ZIO dependencies to our build.sbt:

val zioVersion = "1.0.0-RC18-2"

lazy val compileDependencies = Seq(
  "dev.zio" %% "zio" % zioVersion
) map (_ % Compile)

lazy val testDependencies = Seq(
  "dev.zio" %% "zio-test"     % zioVersion,
  "dev.zio" %% "zio-test-sbt" % zioVersion
) map (_ % Test)

Now, we can begin with the implementation of the LRU Cache. An initial model could be:

final class LRUCache[K, V](
  private val capacity: Int,
  private var items: Map[K, CacheItem[K, V]],
  private var start: Option[K],
  private var end: Option[K]

So, the LRUCache should have:

  • A capacity (which should be a positive integer set on creation and shouldn’t change anymore, which is why it’s modeled as a val).
  • A Map containing items, this will change all the time, which is why we are modeling this as a var. By the way, the model of a CacheItem would be like this:

final case class CacheItem[K, V](value: V, left: Option[K], right: Option[K])

This means that each CacheItem should not just contain a value to be stored, but also references to the left and right keys in the history of referenced keys (remember we’ll use a doubly linked list for keeping a history of referenced keys). These are modeled as Options because, if an item is at the start of the history (meaning it’s the Most Recently Used item), there won’t be any item on its left. Something similar happens when an item is at the end of the history (meaning it’s the Least Recently Used item), there won’t be any item on its right.

  • References to the start and end keys, these will also change all the time, and that’s why they are vars.

There’s a problem with this implementation though: the fact we are resorting to vars. In functional programming, we should model everything as immutable values, and also using vars will make it harder to use the LRUCache in concurrent scenarios (using mutability in our applications instantly makes them prone to race conditions!).

So, what can we do? Well, ZIO has the answer! We can use its Ref[A]data type, which is a purely functional description of a mutable reference. The fundamental operations of a Ref are get and set, and both of them return ZIO effects which describe the operations of reading from and writing to the Ref.

Then, a better (and purely functional) version of our LRUCache would be:

final class LRUCache[K, V](

  private val capacity: Int,

  private val itemsRef: Ref[Map[K, CacheItem[K, V]]],

  private val startRef: Ref[Option[K]],

  private val endRef: Ref[Option[K]]


Now, we can make the constructor private, and create a smart constructor in the companion object:

final class LRUCache[K, V] private (

  private val capacity: Int,

  private val itemsRef: Ref[Map[K, CacheItem[K, V]]],

  private val startRef: Ref[Option[K]],

  private val endRef: Ref[Option[K]]


object LRUCache {

  def make[K, V](capacity: Int): IO[IllegalArgumentException, LRUCache[K, V]] =

    if (capacity > 0) {

      for {

        itemsRef <- Ref.make(Map.empty[K, CacheItem[K, V]])

        startRef <- Ref.make(Option.empty[K])

        endRef   <- Ref.make(Option.empty[K])

      } yield new LRUCache[K, V](capacity, itemsRef, startRef, endRef)

    } else { IllegalArgumentException("Capacity must be a positive number!"))



The make function is our smart constructor, and we can see it expects to receive a capacity, and it returns an effect which can fail with an IllegalArgumentException (when a non-positive capacity is provided) or can succeed with an LRUCache[K, V]. We also know that the LRUCache constructor expects to receive not just the capacity, but also the initial values for itemsRef, startRef and endRef. For creating these Refs, we can use the Ref.make function, which receives the initial value for the Ref and returns a UIO[Ref[A]]. And because ZIO effects are monads (meaning they have map and flatMap methods), we can combine the results of calling Ref.make using for-comprehension syntax, for yielding a new LRUCache.

Now, we can implement the get and put methods for the LRUCache. Let’s start with the get method first:

def get(key: K): IO[NoSuchElementException, V] =

    (for {

      items <- self.itemsRef.get

      item  <- ZIO.fromOption(items.get(key)).mapError(_ => new NoSuchElementException(s"Key does not exist: $key"))

      _     <- removeKeyFromList(key) *> addKeyToStartOfList(key)

    } yield item.value).refineToOrDie[NoSuchElementException]

As you can see, the method implementation looks really nice and simple: it’s practically just a description of what to do when getting an element from the cache:

  • Firstly, we need to get the items Map from the itemsRef
  • Next, we need to obtain the requested key from the items map. This key may or not exist, if it does exist the flow just continues and, if it doesn’t, the method fails with a NoSuchElementException and the flow execution stops.
  • After the item is obtained from the Map, we need to update the history of referenced keys, because the requested key becomes the Most Recently Used one. That’s why we need to call the auxiliary functions removeKeyFromList and addKeyToStartOfList.
  • Finally, the item value is returned, and the error type is refined to be just NoSuchElementException (this is the only error we are expecting to happen and that should be handled when calling get). Any other errors should make the fiber execution die because they are bugs that need to be exposed at execution time and fixed.

Now, let’s see the put method implementation. Again it’s really simple:

def put(key: K, value: V): UIO[Unit] =

    (for {

      optionStart <- self.startRef.get

      optionEnd   <- self.endRef.get

      _ <- ZIO.ifM(

            updateItem(key, value),

            addNewItem(key, value, optionStart, optionEnd)


    } yield ()).orDie

We can see that:

  • The method checks whether the provided key is already present in the items map (again, we are accessing the Map calling the get method on itemsRef):
    • If the key is already present, the updateItem auxiliary function is called.
    • Otherwise, a new item is added, by calling the addNewItem auxiliary function.
  • Finally, the method just yields a unit value and dies in the case of an error. This is because this method should never fail, otherwise there’s a bug that needs to be exposed at runtime and fixed.

Now we can take a look at some auxiliary functions (we won’t go into the details of every auxiliary function, for more details you can take a look at the complete source code in the jorge-vasquez-2301/zio-lru-cache repository). First up, we have the removeKeyFromList function:

private def removeKeyFromList(key: K): IO[Error, Unit] =

    for {

      cacheItem      <- getExistingCacheItem(key)

      optionLeftKey  = cacheItem.left

      optionRightKey = cacheItem.right

      _ <- (optionLeftKey, optionRightKey) match {

            case (Some(l), Some(r)) =>

              updateLeftAndRightCacheItems(l, r)

            case (Some(l), None) =>


            case (None, Some(r)) =>


            case (None, None) =>



    } yield ()

As you can see, the implementation is pretty straightforward, and it considers all the possible cases when removing a key from the history of referenced keys:

  • When the key to be removed has other keys to its left and right, the corresponding cache items have to be updated so they point to each other.
  • When the key to be removed has another key to its left, but not to its right, it means the key to be removed is at the end of the list, so the end has to be updated.
  • When the key to be removed has another key to its right, but not to its left, it means the key to be removed is at the start of the list, so the start has to be updated.
  • When the key to be removed has no keys to left nor right, that means the key to be removed is the only one, so the start and end references have to be cleared.

And here is the getExistingCacheItemfunction implementation:

private def getExistingCacheItem(key: K): IO[Error, CacheItem[K, V]] =

    ZIO.require(new Error(s"Key does not exist: $key"))(

This function is named this way because the idea is that when we use it, we expect that the cache item we want to get exists. If the item does not exist, it means there’s some kind of problem, and we are signaling that with an Error. (By the way, if you look again at the get and put methods of LRUCache, you can see the application will die if an Error is produced).

Another interesting function to look at is updateLeftAndRightCacheItems, because it shows a use case of ZIO Ref.update, which automically modifies an Ref with the specified function.

private def updateLeftAndRightCacheItems(l: K, r: K): IO[Error, Unit] =

    for {

      leftCacheItem  <- getExistingCacheItem(l)

      rightCacheItem <- getExistingCacheItem(r)

      _              <- self.itemsRef.update(_.updated(l, leftCacheItem.copy(right = Some(r))))

      _              <- self.itemsRef.update(_.updated(r, rightCacheItem.copy(left = Some(l))))

    } yield ()

Finally, let’s take a look at the addKeyToStartOfList function, which is also pretty straightforward. Something to notice is that we are using Ref.updateSome for updating the endRef value, when it’s empty.

private def addKeyToStartOfList(key: K): IO[Error, Unit] =

    for {

      oldOptionStart <- self.startRef.get

      _ <- getExistingCacheItem(key).flatMap { cacheItem =>

            self.itemsRef.update(_.updated(key, cacheItem.copy(left = None, right = oldOptionStart)))


      _ <- oldOptionStart match {

            case Some(oldStart) =>

              getExistingCacheItem(oldStart).flatMap { oldStartCacheItem =>

                self.itemsRef.update(_.updated(oldStart, oldStartCacheItem.copy(left = Some(key))))


            case None => ZIO.unit


      _ <- self.startRef.set(Some(key))

      _ <- self.endRef.updateSome { case None => Some(key) }

    } yield ()

There is one more thing to do before testing: create an IntLRUCacheEnv module in the com.example.cache package object (this module will be used for testing, and for simplicity, it considers integer keys and values):

package object cache {
type IntLRUCacheEnv = Has[IntLRUCacheEnv.Service]
object IntLRUCacheEnv {
trait Service {
def getInt(key: Int): IO[NoSuchElementException, Int]
def putInt(key: Int, value: Int): UIO[Unit]
val getCacheStatus: UIO[(Map[Int, CacheItem[Int, Int]], Option[Int], Option[Int])]
object Service {
val zioRefImpl: ZLayer[Has[Int], IllegalArgumentException, IntLRUCacheEnv] =
ZLayer.fromFunctionM { hasInt: Has[Int] =>
LRUCache.make[Int, Int](hasInt.get).map { lruCache =>
new Service {
override def getInt(key: Int): IO[NoSuchElementException, Int] = lruCache.get(key)
override def putInt(key: Int, value: Int): UIO[Unit] = lruCache.put(key, value)
override val getCacheStatus: UIO[(Map[Int, CacheItem[Int, Int]], Option[Int], Option[Int])] =
def getInt(key: Int): ZIO[IntLRUCacheEnv, NoSuchElementException, Int] = ZIO.accessM(_.get.getInt(key))
def putInt(key: Int, value: Int): ZIO[IntLRUCacheEnv, Nothing, Unit] = ZIO.accessM(_.get.putInt(key, value))
val getCacheStatus: ZIO[IntLRUCacheEnv, Nothing, (Map[Int, CacheItem[Int, Int]], Option[Int], Option[Int])] =

By the way, this module is defined based on the new ZLayer data type that comes with ZIO since 1.0.0-RC18, and you can see there’s a zioRefImpl that makes use of our LRUCache. I won’t go into the details of using ZLayer here, but you can read the ZIO documentation page to get more information, and you can also take a look at these really nice articles:

Testing implementation with a single fiber

It’s time to put our LRUCache to test! Firstly, we are going to test it under a single-fiber scenario, the testing code is the following (by the way, this testing code reflects the example shown on this link):

object UseLRUCacheWithOneFiber extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, Int] =
(for {
_ <- put(1, 1)
_ <- put(2, 2)
_ <- get(1)
_ <- put(3, 3)
_ <- get(2)
_ <- put(4, 4)
_ <- get(1)
_ <- get(3)
_ <- get(4)
} yield 0)
.provideCustomLayer(ZLayer.succeed(2) >>> IntLRUCacheEnv.Service.zioRefImpl)
.catchAll(ex => putStrLn(ex.getMessage) *> ZIO.succeed(1))
private def get(key: Int): URIO[Console with IntLRUCacheEnv, Unit] =
(for {
_ <- putStrLn(s"Getting key: $key")
v <- getInt(key)
_ <- putStrLn(s"Obtained value: $v")
} yield ()).catchAll(ex => putStrLn(ex.getMessage))
private def put(key: Int, value: Int): URIO[Console with IntLRUCacheEnv, Unit] =
    putStrLn(s"Putting ($key, $value)") *> putInt(key, value)

So, we are running the application with an IntLRUCacheEnv.Service.zioRefImpl, with a capacity of 2. After executing the above program, the following result is obtained:

Putting (1, 1)

Putting (2, 2)

Getting key: 1

Obtained value: 1

Putting (3, 3)

Getting key: 2

Key does not exist: 2

Putting (4, 4)

Getting key: 1

Key does not exist: 1

Getting key: 3

Obtained value: 3

Getting key: 4

Obtained value: 4

As we can see, the behavior is correct! So, our implementation looks good so far.

Testing implementation with multiple concurrent fibers

Now, let’s test the LRUCache again, but against multiple concurrent fibers this time. The testing code is the following:

object UseLRUCacheWithMultipleFibers extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, Int] =
(for {
fiberReporter  <- reporter.forever.fork
fiberProducers <- ZIO.forkAll(ZIO.replicate(100)(producer.forever))
fiberConsumers <- ZIO.forkAll(ZIO.replicate(100)(consumer.forever))
_              <- getStrLn.orDie *> (fiberReporter <*> fiberProducers <*> fiberConsumers).interrupt
} yield 0)
.provideCustomLayer(ZLayer.succeed(3) >>> IntLRUCacheEnv.Service.zioRefImpl)
.catchAll(ex => putStrLn(ex.getMessage) *> ZIO.succeed(1))
val producer: URIO[Console with Random with IntLRUCacheEnv, Unit] =
for {
number <- nextInt(100)
_      <- putStrLn(s"Producing ($number, $number)")
_      <- putInt(number, number)
} yield ()
val consumer: URIO[Console with Random with IntLRUCacheEnv, Unit] =
(for {
key   <- nextInt(100)
_     <- putStrLn(s"Consuming key: $key")
value <- getInt(key)
_     <- putStrLn(s"Consumed value: $value")
} yield ()).catchAll(ex => putStrLn(ex.getMessage))
val reporter: ZIO[Console with IntLRUCacheEnv, NoSuchElementException, Unit] =
for {
(items, optionStart, optionEnd) <- getCacheStatus
_                               <- putStrLn(s"Items: $items, Start: $optionStart, End: $optionEnd")
} yield ()

We can see that an IntLRUCacheEnv.Service.zioRefImpl with a  capacity of 3 is provided. Also, 100 producers and 100 consumers of random integers are started in different fibers, and we have a reporter that will just print to the console the cache current status (stored items, start and end keys of the recently used items history). When we execute this, some ugly stuff happens:

  • First, more items than the defined capacity (a lot more) are being stored! And also, stored items have a lot of inconsistencies: for example you can see below that, for a given moment, the end key (the Least Recently Used key) is 97, but looking at the corresponding CacheItem we see it has other keys to its left and right (58 and 9 respectively), but… if 97 is at the end of the list, it shouldn’t have an item to its right!,Besides this, there are a lot more discrepancies among CacheItems:

Items: HashMap(5 -> CacheItem(5,Some(45),Some(6)), 84 -> CacheItem(84,Some(51),Some(91)), 69 -> CacheItem(69,Some(83),Some(36)), 0 -> CacheItem(0,None,Some(37)), 88 -> CacheItem(88,Some(82),Some(94)), 10 -> CacheItem(10,Some(37),Some(45)), 56 -> CacheItem(56,Some(54),Some(42)), 42 -> CacheItem(42,Some(6),Some(60)), 24 -> CacheItem(24,Some(30),Some(18)), 37 -> CacheItem(37,Some(0),Some(10)), 52 -> CacheItem(52,Some(70),Some(91)), 14 -> CacheItem(14,Some(72),Some(1)), 20 -> CacheItem(20,None,Some(46)), 46 -> CacheItem(46,Some(28),Some(70)), 93 -> CacheItem(93,Some(40),Some(6)), 57 -> CacheItem(57,Some(12),Some(45)), 78 -> CacheItem(78,None,Some(41)), 61 -> CacheItem(61,None,Some(26)), 1 -> CacheItem(1,Some(14),Some(2)), 74 -> CacheItem(74,None,Some(33)), 6 -> CacheItem(6,Some(5),Some(42)), 60 -> CacheItem(60,Some(42),Some(80)), 85 -> CacheItem(85,None,Some(99)), 70 -> CacheItem(70,Some(46),Some(52)), 21 -> CacheItem(21,None,Some(65)), 33 -> CacheItem(33,Some(77),Some(32)), 28 -> CacheItem(28,None,Some(46)), 38 -> CacheItem(38,Some(98),Some(68)), 92 -> CacheItem(92,Some(63),Some(0)), 65 -> CacheItem(65,Some(21),Some(51)), 97 -> CacheItem(97,Some(58),Some(9)), 9 -> CacheItem(9,Some(97),Some(99)), 53 -> CacheItem(53,None,Some(91)), 77 -> CacheItem(77,Some(27),Some(33)), 96 -> CacheItem(96,Some(3),Some(58)), 13 -> CacheItem(13,Some(14),Some(28)), 41 -> CacheItem(41,Some(78),Some(90)), 73 -> CacheItem(73,None,Some(41)), 2 -> CacheItem(2,Some(1),Some(92)), 32 -> CacheItem(32,Some(33),Some(98)), 45 -> CacheItem(45,Some(10),Some(5)), 64 -> CacheItem(64,None,Some(34)), 17 -> CacheItem(17,None,Some(35)), 22 -> CacheItem(22,None,Some(7)), 44 -> CacheItem(44,Some(79),Some(92)), 59 -> CacheItem(59,Some(15),Some(68)), 27 -> CacheItem(27,Some(4),Some(77)), 71 -> CacheItem(71,Some(46),Some(19)), 12 -> CacheItem(12,Some(75),Some(57)), 54 -> CacheItem(54,None,Some(56)), 49 -> CacheItem(49,None,Some(63)), 86 -> CacheItem(86,None,Some(43)), 81 -> CacheItem(81,Some(98),Some(1)), 76 -> CacheItem(76,None,Some(35)), 7 -> CacheItem(7,Some(22),Some(33)), 39 -> CacheItem(39,None,Some(4)), 98 -> CacheItem(98,Some(32),Some(81)), 91 -> CacheItem(91,Some(52),Some(75)), 66 -> CacheItem(66,None,Some(27)), 3 -> CacheItem(3,Some(94),Some(96)), 80 -> CacheItem(80,Some(60),Some(84)), 48 -> CacheItem(48,None,Some(9)), 63 -> CacheItem(63,Some(49),Some(3)), 18 -> CacheItem(18,Some(24),Some(26)), 95 -> CacheItem(95,None,Some(65)), 50 -> CacheItem(50,Some(68),Some(58)), 67 -> CacheItem(67,None,Some(21)), 16 -> CacheItem(16,None,Some(82)), 11 -> CacheItem(11,Some(5),Some(73)), 72 -> CacheItem(72,Some(99),Some(14)), 43 -> CacheItem(43,Some(86),Some(3)), 99 -> CacheItem(99,Some(9),Some(72)), 87 -> CacheItem(87,Some(36),Some(46)), 40 -> CacheItem(40,Some(11),Some(93)), 26 -> CacheItem(26,Some(18),Some(16)), 8 -> CacheItem(8,Some(3),Some(0)), 75 -> CacheItem(75,Some(91),Some(12)), 58 -> CacheItem(58,Some(96),Some(97)), 82 -> CacheItem(82,Some(16),Some(88)), 36 -> CacheItem(36,Some(69),Some(87)), 30 -> CacheItem(30,Some(11),Some(24)), 51 -> CacheItem(51,Some(65),Some(84)), 19 -> CacheItem(19,None,Some(83)), 4 -> CacheItem(4,Some(62),Some(27)), 79 -> CacheItem(79,None,Some(44)), 94 -> CacheItem(94,Some(88),Some(3)), 47 -> CacheItem(47,Some(35),Some(37)), 15 -> CacheItem(15,Some(68),Some(59)), 68 -> CacheItem(68,Some(38),Some(50)), 62 -> CacheItem(62,None,Some(4)), 90 -> CacheItem(90,Some(41),Some(33)), 83 -> CacheItem(83,Some(19),Some(69))), Start: Some(16), End: Some(97)

  • And, because of the issues mentioned above, we see fibers dying because of unexpected errors like this:
Fiber failed.
An unchecked error was produced.
java.lang.Error: Key does not exist: 35
at io.scalac.LRUCache.$anonfun$getExistingCacheItem$1(LRUCache.scala:113)

And well, it seems our current LRUCache implementation just works correctly in a single-fiber scenario, but not in a concurrent scenario, that is really bad. So now, let’s reflect on what’s happening.

Why doesn’t our implementation work in a concurrent scenario?

It may seem weird that our current implementation does not work as expected when multiple fibers use it concurrently: We have used immutable values everywhere, pure functions, purely functional mutable references (Ref[A]) that provide atomic operations on them… but wait a moment, Ref[A] provides atomic operations on SINGLE VALUES, but what happens if we need to keep consistency across MULTIPLE VALUES? Remember that in our LRUCache implementation, we have three Refs: itemsRef, startRef and endRef. So, it seems using Ref[A] is not a powerful enough solution for our use case:

  • Refs allow atomic operations on single values only.
  • Refs don’t compose! So you can’t compose two Refs to get a resulting Ref.

So, what can we do now?

Enter ZIO STM!

The solution to our problem is using ZIO STM (Software Transactional Memory). For that, ZIO provides two basic data types:

  • ZSTM[-R, +E, +A]: Represents an effect that can be performed transactionally, that requires an environment R to run and that may fail with an error E or succeed with a value A. Also, ZIO provides a type alias when no environment R is required: STM[+E, +A], which is equivalent to ZSTM[Any, E, A].
  • TRef[A]: Represents a Transactional Ref, meaning a purely functional mutable reference that can be used in the context of a transaction.

So, basically, an STM describes a bunch of operations across several TRefs.

Important things to notice:

  • STMs are composable (we can use them in for-comprehensions!)
  • All methods in TRef are very similar to the ones in Ref, but they return STM effects instead of ZIO effects.
  • To convert a STM effect to a ZIO effect, you need to commit the transaction: When you commit a transaction, all of its operations are performed in an atomic, consistent and isolated fashion, very similar to how relational databases work.

It’s also worth mentioning that we could use other classic concurrency structures from java.util.concurrent like Locks and Semaphores for solving concurrency issues, but that’s really complicated, low-level and error-prone, and race conditions or deadlocks are very likely to happen. Instead, ZIO STM replaces all of this low-level machinery with a high-level concept: transactions in memory, and we have no race conditions and no deadlocks!

Finally, ZIO STM provides other nice data structures that can participate in transactions (all of them are based in TRef):

  • TArray
  • TQueue
  • TSet
  • TMap
  • TPromise
  • TReentrantLock
  • TSemaphore

Our LRU Cache goes concurrent! Moving from ZIO Ref to ZIO STM

The concurrent version of our LRUCache will be very similar to what we had before, but we are going to make some changes to use ZIO STM:

final class ConcurrentLRUCache[K, V] private (
private val capacity: Int,
private val items: TMap[K, CacheItem[K, V]],
private val startRef: TRef[Option[K]],
private val endRef: TRef[Option[K]]

As you can see, we are changing Refs to TRefs, and instead of having items: TRef[Map[K, CacheItem[K, V]]], we are using the more convenient and efficient TMap data type that ZIO STM provides.

The smart constructor will also be very similar to the one we had before:

object ConcurrentLRUCache {
def make[K, V](capacity: Int): IO[IllegalArgumentException, ConcurrentLRUCache[K, V]] =
if (capacity > 0) {
(for {
itemsRef <- TMap.empty[K, CacheItem[K, V]]
startRef <- TRef.make(Option.empty[K])
endRef   <- TRef.make(Option.empty[K])
} yield new ConcurrentLRUCache[K, V](capacity, itemsRef, startRef, endRef)).commit
} else { IllegalArgumentException("Capacity must be a positive number!"))

The biggest difference in this smart constructor is that the for-comprehension returns a STM[Nothing, ConcurrentLRUCache[K, V]], and we need to commit it for getting a ZIO effect, which is what we want to return.

Next, we have the get and put methods for the ConcurrentLRUCache

def get(key: K): IO[NoSuchElementException, V] =
(for {
optionItem <- self.items.get(key)
item      <- STM.fromOption(optionItem).mapError(_ => new NoSuchElementException(s"Key does not exist: $key"))
_          <- removeKeyFromList(key) *> addKeyToStartOfList(key)
} yield item.value).commitEither.refineToOrDie[NoSuchElementException]
def put(key: K, value: V): UIO[Unit] =
(for {
optionStart <- self.startRef.get
optionEnd   <- self.endRef.get
_           <- STM.ifM(self.items.contains(key))(updateItem(key, value), addNewItem(key, value, optionStart, optionEnd))
} yield ()).commitEither.orDie

Again, you can see these methods are very similar to the ones we had before! The only difference is that the for-comprehensions in both methods return values of type STM, so we need to commit the transactions (we are using commitEither in this case, so transactions are always committed despite errors, and failures are handled at the ZIO level).

Now, we can take a look at the same auxiliary functions we’ve seen before, but this time with ZIO STM. First, we have the removeKeyFromList function:

private def removeKeyFromList(key: K): STM[Error, Unit] =
for {
cacheItem      <- getExistingCacheItem(key)
optionLeftKey  = cacheItem.left
optionRightKey = cacheItem.right
_ <- (optionLeftKey, optionRightKey) match {
case (Some(l), Some(r)) =>
              updateLeftAndRightCacheItems(l, r)
case (Some(l), None) =>
case (None, Some(r)) =>
case (None, None) =>
} yield ()

As you may have realized, the implementation is practically the same! The difference is that the function returns a STM effect instead of a ZIO effect. In this case (and the same happens for all private methods) we are not committing the transaction yet, that’s because we want to use these private functions in combination with others, to form bigger transactions that are committed in the get and put methods.

And, here is the getExistingCacheItem function implementation, again it’s very similar to the one we had before, but now an STM effect is returned, and also getting an element from the items Map is a lot easier now, thanks to TMap:

private def getExistingCacheItem(key: K): STM[Error, CacheItem[K, V]] =
STM.require(new Error(s"Key does not exist: $key"))(self.items.get(key))

And for updateLeftAndRightCacheItems, putting elements into the items Map is a lot easier now too:

private def updateLeftAndRightCacheItems(l: K, r: K): STM[Error, Unit] =
for {
leftCacheItem  <- getExistingCacheItem(l)
rightCacheItem <- getExistingCacheItem(r)
_              <- self.items.put(l, leftCacheItem.copy(right = Some(r)))
_              <- self.items.put(r, rightCacheItem.copy(left = Some(l)))
} yield ()

And, we have addKeyToStartOfList, which again is very similar to the previous version:

private def addKeyToStartOfList(key: K): STM[Error, Unit] =
for {
oldOptionStart <- self.startRef.get
_ <- getExistingCacheItem(key).flatMap { cacheItem =>
self.items.put(key, cacheItem.copy(left = None, right = oldOptionStart))
_ <- oldOptionStart match {
case Some(oldStart) =>
              getExistingCacheItem(oldStart).flatMap { oldStartCacheItem =>
self.items.put(oldStart, oldStartCacheItem.copy(left = Some(key)))
case None => STM.unit
_ <- self.startRef.set(Some(key))
_ <- self.endRef.updateSome { case None => Some(key) }
} yield ()

Finally, before testing, let’s add a new zioStmImpl to the IntLRUCacheEnv module. This implementation should make use of the ConcurrentLRUCache we’ve just created:

object IntLRUCacheEnv {
object Service {
val zioStmImpl: ZLayer[Has[Int], IllegalArgumentException, IntLRUCacheEnv] =
ZLayer.fromFunctionM { hasInt: Has[Int] =>
ConcurrentLRUCache.make[Int, Int](hasInt.get).map { concurrentLruCache =>
new Service {
override def getInt(key: Int): IO[NoSuchElementException, Int] = concurrentLruCache.get(key)
override def putInt(key: Int, value: Int): UIO[Unit] = concurrentLruCache.put(key, value)
override val getCacheStatus: UIO[(Map[Int, CacheItem[Int, Int]], Option[Int], Option[Int])] =

Testing implementation with multiple fibers

Now that we have our ConcurrentLRUCache, let’s put it to test with the following testing code, which is practically the same one we had before (the only difference is that we are providing a IntLRUCacheEnv.Service.zioStmImpl now):

object UseConcurrentLRUCacheWithMultipleFibers extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, Int] =
(for {
fiberReporter  <- reporter.forever.fork
fiberProducers <- ZIO.forkAll(ZIO.replicate(100)(producer.forever))
fiberConsumers <- ZIO.forkAll(ZIO.replicate(100)(consumer.forever))
_              <- getStrLn.orDie *> (fiberReporter <*> fiberProducers <*> fiberConsumers).interrupt
} yield 0)
.provideCustomLayer(ZLayer.succeed(3) >>> IntLRUCacheEnv.Service.zioStmImpl)
.catchAll(ex => putStrLn(ex.getMessage) *> ZIO.succeed(1))
val producer: URIO[Console with Random with IntLRUCacheEnv, Unit] =
for {
number <- nextInt(100)
_      <- putStrLn(s"Producing ($number, $number)")
_      <- putInt(number, number)
} yield ()
val consumer: URIO[Console with Random with IntLRUCacheEnv, Unit] =
(for {
key   <- nextInt(100)
_     <- putStrLn(s"Consuming key: $key")
value <- getInt(key)
_     <- putStrLn(s"Consumed value: $value")
} yield ()).catchAll(ex => putStrLn(ex.getMessage))
val reporter: ZIO[Console with IntLRUCacheEnv, NoSuchElementException, Unit] =
for {
(items, optionStart, optionEnd) <- getCacheStatus
_                               <- putStrLn(s"Items: $items, Start: $optionStart, End: $optionEnd")
} yield ()

When we run this, everything works as it should! (and the best part is, we didn’t need to use Locks at all!) No more unexpected errors, and the reporter shows our cache keeps internal consistency, this is an example of what is printed to console for two executions of the reporter:

Items: Map(43 -> CacheItem(43,Some(16),None), 16 -> CacheItem(16,Some(32),Some(43)), 32 -> CacheItem(32,None,Some(16))), Start: Some(32), End: Some(43)

Items: Map(30 -> CacheItem(30,None,Some(69)), 53 -> CacheItem(53,Some(69),None), 69 -> CacheItem(69,Some(30),Some(53))), Start: Some(30), End: Some(53)

Writing unit tests for the Concurrent LRU Cache using ZIO Test

Finally, we can write some unit tests for our ConcurrentLRUCache using zio-test:

object ConcurrentLRUCacheTest extends DefaultRunnableSpec {
def spec = suite("ConcurrentLRUCache")(
    testM("can't be created with non-positive capacity") {
      assertM(ConcurrentLRUCache.make[Int, Int](0).run)(
        fails(hasMessage(equalTo("Capacity must be a positive number!")))
    testM("works as expected") {
val expectedOutput = Vector(
"Putting (1, 1)\n",
"Putting (2, 2)\n",
"Getting key: 1\n",
"Obtained value: 1\n",
"Putting (3, 3)\n",
"Getting key: 2\n",
"Key does not exist: 2\n",
"Putting (4, 4)\n",
"Getting key: 1\n",
"Key does not exist: 1\n",
"Getting key: 3\n",
"Obtained value: 3\n",
"Getting key: 4\n",
"Obtained value: 4\n"
for {
lruCache <- ConcurrentLRUCache.make[Int, Int](2)
_        <- put(lruCache, 1, 1)
_        <- put(lruCache, 2, 2)
_        <- get(lruCache, 1)
_        <- put(lruCache, 3, 3)
_        <- get(lruCache, 2)
_        <- put(lruCache, 4, 4)
_        <- get(lruCache, 1)
_        <- get(lruCache, 3)
_        <- get(lruCache, 4)
output   <- TestConsole.output
} yield {
private def get[K, V](concurrentLruCache: ConcurrentLRUCache[K, V], key: K): ZIO[Console, Nothing, Unit] =
(for {
_ <- putStrLn(s"Getting key: $key")
v <- concurrentLruCache.get(key)
_ <- putStrLn(s"Obtained value: $v")
} yield ()).catchAll(ex => putStrLn(ex.getMessage))
private def put[K, V](concurrentLruCache: ConcurrentLRUCache[K, V], key: K, value: V): ZIO[Console, Nothing, Unit] =
    putStrLn(s"Putting ($key, $value)") *> concurrentLruCache.put(key, value)

The first test is for asserting that trying to create a ConcurrentLRUCache with a non-positive capacity would result in a failure.

The second test is for asserting that the cache works as expected, for that we use the TestConsole module provided by zio-test, for asserting that the expected messages are printed to the console. 

I won’t go into more details about how zio-test works, but you can read about it in the ZIO documentation page.


In this article, we’ve seen a concrete example of writing concurrent data structures with ZIO: a concurrent LRU cache.  Because ZIO is based on functional programming principles – such as using pure functions and immutable values – it was really easy and painless to evolve our initial implementation. This didn’t support concurrency and was based on ZIO Ref, to a fully concurrent version, based on ZIO STM, without all the complicated stuff that comes when using lower-level concurrency structures such as Locks, and with no deadlocks or race conditions at all.

In addition, this was just a very specific example of what you can do with ZIO STM for writing concurrent data structures, so there’s a lot more you can do with it in your own projects, in a totally async, non-blocking and thread-safe fashion. So make sure to give it a try!