Service Objects With Dry.rb: Monads and Transactions

Summarized using AI

Service Objects With Dry.rb: Monads and Transactions

Paul Sadauskas • November 08, 2021 • Denver, CO • Talk

The video titled Service Objects With Dry.rb: Monads and Transactions features speaker Paul Sadauskas at RubyConf 2021, focusing on the significance of service objects and the capabilities of the Dry.rb Transaction library within Ruby. The presentation emphasizes various design patterns and how to effectively structure business logic, moving away from traditional Rails MVC patterns, which often lead to complex and unmanageable code.

Key points discussed in the video include:

- Transition from MVC Paradigm: Sadauskas discusses the initial approach to business logic in Rails, where the focus was placed on fat models or controllers, contributing to code that was hard to maintain.

- Service Objects: He introduces the concept of service objects, which encapsulate business logic away from controllers and models, thus promoting cleaner and more manageable code.

- Dry Transaction Library: The core of the talk is about the Dry Transaction library. It is framed as a means to encapsulate business logic steps in a clear, declarative manner. Each step in a transaction can either succeed or fail without affecting subsequent steps unnecessarily, as failures are anticipated and handled gracefully.

- Monads in Ruby: Sadauskas simplifies the concept of monads, discussing how they can be used to encapsulate values, especially dealing with nil values effectively to prevent runtime errors. He illustrates the use of the Maybe monad to avoid common errors associated with nil values, further promoting safer code patterns.

- Result Monad: In addition, he differentiates the Result monad, which conveys success or failure with more detail, adding context to why a transaction may fail, thus enhancing error handling strategies.

- DSL for Transactions: Dry Transaction uses a domain-specific language (DSL) for defining discrete steps, making the code easier to read and maintain. Each step can handle its own errors, leading to clearer error flows within the application.

- Testing and Extending Transactions: Sadauskas highlights how transactions can be tested more easily due to their structured approach, allowing various implementations for unit tests without extensive mock setups.

In conclusion, the talk advocates for adopting a functional coding style in Ruby applications through the use of Dry.rb and monads, which improves maintainability, robustness, and testability of code. By abstracting the complexities of business transactions and prioritizing error handling, developers can create cleaner interfaces for their applications.

Overall, Sadauskas encourages developers to explore and utilize the Dry.rb libraries and functional programming concepts to enhance their coding practices.

Service Objects With Dry.rb: Monads and Transactions
Paul Sadauskas • November 08, 2021 • Denver, CO • Talk

Service objects are an important tool in your toolbox, and Dry.rb's Transaction library is one of the most powerful, and one of the most magic. It's a "business transaction" DSL, and has error handling as a primary concern. We'll start by exploring Monads in Ruby (they're not scary!). Then we'll see how that simple concept unlocks another level of service objects that are far more robust and testable, and how to wire them all together with Dry::Transaction. Finally we'll touch on extending transactions with custom steps, and how to integrate them into an existing application.

RubyConf 2021

00:00:10.400 I'm the lead architect at Texas. I would normally say TextUs, just up the road in Boulder, but that's not the case anymore. We don't really have our office; we've gone fully remote and have people all over the country, and even a few outside.
00:00:16.240 We're hiring just like everybody else, and I’d like to point out that this is a version of a similar talk I gave at the local Boulder Ruby group about two years ago. If you listened to the Ruby on Rails podcast last week, you heard a glowing review from Jason, who works with us.
00:00:27.920 Also, one of the co-hosts of the podcast, Brittany, is our engineering lead. She wasn't able to make it to the conference, but most of the rest of the team, including Jason, is here, and they're right over there. So if you want to talk to anyone afterward, there they are.
00:00:39.920 If you hear any heckling, it's probably them, so just ignore it; they'll be disciplined after the talk. All right, a fair warning: this talk is going to be pretty detailed and code-heavy, so we'll just jump right into it.
00:00:58.320 Like most everyone else, I came to Ruby by way of Rails, and I've been doing it for over 15 years at this point. In the early days of Rails, there was the problem of where to put all my business logic. All we knew at the time was MVC: Model, View, Controller.
00:01:11.360 Your business logic clearly didn't go in the view, so it had to go in the model or the controller, right? And that was the big debate in 2009: fat controllers or fat models?
00:01:17.840 The Rails way for a long time was to put it all in your models, and if you had something that involved multiple models, it went in the controller. If you had to reuse it in multiple controller actions, then you would put it in a helper.
00:01:30.079 Luckily, it's now accepted in Rails specifically, and web application development in general, that there's a huge variety of service objects, each serving a specific purpose, and that's where you put all your business logic.
00:01:41.439 Plus, non-Rails frameworks like Hanami fully embrace having tiny objects that serve a specific purpose.
00:01:46.799 As Rails grew in popularity, it became clear that fat models weren't a good solution because you ended up with a huge mess of unmaintainable and potentially dead code.
00:01:53.520 Some more forward-thinking Ruby developers started exploring and adopting code design patterns found in other languages, then blogging and giving talks about it. Oftentimes, they reference patterns defined in design patterns known as the Gang of Four book or 'Patterns of Enterprise Application Architecture' by Martin Fowler.
00:02:06.799 These patterns were mostly tailored for C++ and Java, and most of them turned out to be completely unnecessary in a flexible and dynamic language like Ruby. However, there's a handful that work extremely well in Ruby and Rails, and you probably used a few of them without realizing it, or you used a gem that implements one or more of them.
00:02:29.200 If you haven't seen it yet, refactoring.guru is an amazing resource that details a bunch of these service objects and more general code patterns. They have sample code in Ruby, and a large portion of the site is even dedicated to refactoring code examples, like "I have this gnarly code, what pattern should I use to refactor it, and in the end, what will the code look like?" I strongly recommend checking it out.
00:02:52.800 I reference it several times a month when I'm attempting to clean up gross code because I think there's a better pattern out there but can't remember what it's called.
00:03:03.280 The library we're going to be talking about, Dry::Transaction, is an extremely powerful implementation of the command pattern. You can usually recognize the command pattern in Ruby as an object that defines a call method, although this is not always the case.
00:03:21.599 It's common to see a command object that has one or a few public methods named as verbs. Here we have a fairly simple example: we can initialize it with some configuration, inject some dependencies, and then call it with some arguments to hopefully achieve some cool results.
00:03:35.120 A good application of the command pattern might be the steps required to authenticate a user. Here, we have some code that could typically be found as a helper method in a Rails controller to decode a JWT token and find the user in the database.
00:03:50.879 First, we decode and verify the token, then look up the user by the token. We add the user we find to some context, like error tracking or analytics, and finally, we return the user. To improve this, let's extract that code into a command object: here are exactly the same steps just extracted into the call method of an AuthenticateUser class.
00:04:12.720 When I roll my own command object, I like to name the class as a verb—'AuthenticateUser.' It initializes with some optional configuration and injected dependencies; that’s a topic for another talk. Then there's a single method, call, which takes some input and does some things before finally returning the output.
00:04:29.680 While this code is okay and certainly a lot better than doing the same thing in the controller, it’s still not great. It's procedural in that it follows a fairly linear series of steps, but the nature of the process is such that several things could go wrong. There could be no token, an invalid token, or an expired token.
00:04:51.760 The JWT gem handles these conditions by raising exceptions, so we need to rescue them, which happens non-linearly. Our eyes then have to jump to the bottom of the method to see how the errors are managed.
00:05:10.479 The token could be technically valid but missing a key bit of data. Here, we need to ensure we have the 'sub' key in the payload, so we return early if it's missing. The user could be missing or not found; they might have been deleted or had their account suspended. All three of these problems need to be handled differently, often with different HTTP status codes, response bodies, or redirects.
00:05:27.840 This code provides no means for the caller to detect errors beyond just returning a user or nil. We could handle that better with multiple return types or by raising exceptions to be rescued, but that would just mean more conditionals and further break the flow of the code.
00:05:46.720 Regardless, it's a good first step; it'll get you a long way if you're careful about the sizes of these objects and how you compose them. Oftentimes, you'll want one command to call another, which can cascade in a way that’s difficult to trace. While this is helpful for encapsulating a small set of business logic in one place, and it’s definitely an improvement for testing, it doesn't assist much with error handling.
00:06:10.080 If one of the commands in this long chain fails, how do you know which one is responsible for detecting that error and letting the rest of the system know? Let's skip ahead a bit and see if we can refactor the command object into a Dry::Transaction.
00:06:28.880 Don’t worry; by the end of this talk, you'll understand what all of this means. This is what a command object would look like in our application at TextUs. First, notice how we've broken out all the implicit procedural logic into an explicit list of discrete steps.
00:06:47.680 Each of these steps runs sequentially, passing the output of one to the next. Each can fail individually, and the transaction stops right there, meaning none of the further steps are run. Each step explicitly declares what failing means: try means rescue those exceptions, and failure checks mean it’s a failure unless the step returns something truthy.
00:07:07.520 Similarly, 'T' means the step can’t fail—throw away the output of the step, kind of like the 'tap' method in the Ruby standard library. The really cool stuff happens in the controller, though. When the transaction is called, it also takes a block that matches on the result.
00:07:22.560 Success means every step passed, and it passes the output of the transaction to the block. If any of the steps fail, then it calls the branch with the matching step name. The matcher code I have here is somewhat redundant; it’s not really how we do it in our application, but I wanted to be explicit in this first example.
00:07:42.880 Also, at the top of the transaction, there's some magic happening: the 'include ApplicationImport' leverages a couple of Dry.rb libraries to discard some of the boilerplate. That’s another superpower of the Dry libraries, and I totally should do another talk on that. So, how does all of this work? We're going to take some detours, but we'll come back, and all of this will make sense.
00:08:06.559 Dry::Transaction is part of the Dry Ruby suite of gems, which are interrelated and build upon one another into more advanced layers. Even if you don't use any of the gems, there's a lot of interesting ideas to explore within the code; it will likely improve your personal coding style.
00:08:23.520 The source code is fascinating, showcasing a lot of advanced Ruby tricks you can learn from reviewing it. It's very much in the original spirit of the Ruby language, rather than the dialect familiar to developers who exclusively use Rails. Dry.rb is also the foundation for ROM, the Ruby Object Mapper, which is an ambitious project aimed at providing an alternative to Active Record, not only for relational databases but also for NoSQL databases and even HTTP APIs.
00:08:46.496 It’s also a pretty interesting project worth looking into, and it’s related to Hanami, which is an alternative web framework to Rails but offers a lot cleaner code and sounder engineering principles from the ground up. It leverages many of the Dry.rb libraries and ROM, providing several interesting ideas.
00:09:02.480 So, what we're going to talk about is Dry::Transaction. It's one of the higher level gems that builds upon several others. You don't have to read all this; it's all in the README. But I'm going to paraphrase and point out the important parts.
00:09:26.640 The term 'transaction' here differs from what you might know in a database context. Instead, this refers to a business transaction, which means it encapsulates a set of business logic in your application. A transaction might involve several discrete steps executed one after the other, and it probably requires many different objects to work together.
00:09:48.800 If any of those steps fail, the transaction stops processing, and handling those failures is a first-class concern. The steps are declared in a DSL that allows you to view them from an abstract level, without being coupled to how they are implemented.
00:10:09.680 It doesn't retain state; it doesn't accumulate state while it runs. The same input should always produce the same output. It only has one public method, call, which takes some input. Errors are expected and handled as part of normal application flow; they are not exceptional.
00:10:31.600 To explore what a transaction is, let's start with a really simple example that determines what name we should use for a user given their ID. It involves two steps: one looks up the user in a database or similar, while the second figures out a friendly name to call them based on the attributes of the user we found.
00:10:50.760 Each step is called in the order it's stated in the step DSL at the top of the class, and each step is expected to return a result. If it's successful, it proceeds to the next step. If a particular step returns a failure, the transaction halts there, and that failure is returned.
00:11:14.160 I keep discussing the terms 'result' and 'success' and 'failure.' So what do they mean? This idea is borrowed from typed languages like Haskell, Elm, or Rust, and it’s called a monad. A monad can be a fairly esoteric abstract concept, but let's simplify it. For our purposes, it’s easiest to think of a monad as a set of wrappers that implement the same API signatures while doing completely different things.
00:11:34.960 The simplest monad is the 'Maybe' monad, which has two types of wrappers: one for values and another for nil. A 'Maybe' monad has only two possibilities: 'Nothing' or 'None,' which is equivalent to nil, and 'Just,' which holds an actual value.
00:11:50.640 In a strongly typed language like Elm or Rust, we can declare a function to have an argument as a 'Maybe.' It will be called with either a 'Just' or 'Nothing,' and if we try to use that value directly, the compiler alerts us. We can use pattern matching to unwrap the 'Maybe' and access the value.
00:12:06.560 Not only that, but the Elm compiler detects if we neglect to address both possible types, notifying us with a useful error message if we happen to leave out a case.
00:12:28.640 You know how there's a collective term for groups of animals? Like a murder of crows or a pot of whales? Well, when it comes to programmers, you might say it's an argument—because programmers can never agree on anything.
00:12:51.440 Since different programming languages all have varying names for the same monads and types, there’s a chart I’ll refer to. From here on out, we'll use the Dry Monad terminology.
00:13:03.920 So, why would you use the Maybe monad? It helps to prevent a class of errors that typically only present themselves in production after you've deployed and the site goes down. I'm sure everyone has experienced the infamous 'undefined method for nil' error.
00:13:34.560 Since the Maybe wraps the nil within something else, you can never accidentally call methods on nil, thus avoiding this nightmare for developers.
00:13:56.000 In Ruby, we can utilize the Dry Monads gem to access various advantages of monads. Dry Monads provides a Maybe monad, using the terms 'Some' for successful values and 'None' for nil.
00:14:10.640 Since it’s Ruby, both classes are duck types, behaving similarly and responding to the same methods despite differing implementations. Dry Monads also supports Ruby 2.7 pattern matching, allowing us to implement the same logic as the Elm example from earlier.
00:14:28.600 We don't have a compiler to raise alerts for improper types, so exceptions still arise at runtime when necessary. However, at least these cases happen in general cases rather than in edge scenarios that might slip by unnoticed.
00:14:46.640 In our contrived example, let’s assume we have two methods that can return nil. If the first works, we want to call the second. If the second works, we handle the resulting value; if neither work, we return a different value.
00:15:01.280 Typically, in standard Ruby, we might write something rather convoluted, causing our eyes to jump around to piece everything together. This code isn’t great and tends to prompt code smells with safe navigation operators and early returns.
00:15:17.920 If both methods might return nil, they’re prime candidates for implementing the Maybe monad—this will simplify handling these cases.
00:15:35.680 So, Dry Monads implements a Maybe method that takes a value or nil. If the argument is nil, it returns None; for any other value, it returns Some. We've adjusted 'find_user' to return a Maybe, wrapping the user friendly name.
00:15:54.720 We can now safely assume it has a user, allowing us to eliminate safe navigation operators. Both attributes could still return nil, so we wrap them in a Maybe as well. Finally, we use methods implemented on the Some and None objects, like bind, fmap, or value.
00:16:10.560 If the monad is a Some, then the bind will yield the value, wrapping the block and returning the result. If it’s a None, it doesn’t yield the block, just returning itself.
00:16:28.160 Fmap works like bind is an alternative name for functor map. In our case, it's a shortcut that will wrap the block's return value in a Maybe. For bind, we did it explicitly, but with fmap, we can return the string directly.
00:16:43.680 We also know that ‘name’ in the block will consistently be there, as if it wasn’t, our monad would be None. Finally, value or is a safe way to unwrap the value; if the monad is a Some, it returns the value; otherwise, it yields the block and returns that instead.
00:17:00.640 This guarantees we get a string out of this process even if 'find_user' or 'friendly_name' return nil without utilizing early returns or safe navigation.
00:17:17.120 The implementation is pretty straightforward in Ruby. If bind is called on a Some, it yields the value to the block, anticipating the block to return another monad.
00:17:37.000 If bind is called on a None, it doesn't call the block, it simply returns itself. Fmap mimics bind but wraps the result in a Maybe.
00:17:52.000 Value or serves as the opposite of bind and fmap, providing a safe way to unwrap the value in the Some. For a None, it calls the block and yields that result instead.
00:18:10.880 The monad can also implement a few additional methods to inspect types, like 'some' and 'none', which let you verify what kind it is. There’s also an unsafe value bang method that unpacks the sum and retrieves the value but raises an exception if called on None, which is merely helpful once you’ve verified it's a Some.
00:18:27.760 There are various other methods documented on the Dry Ruby documentation site, but these basic methods open up some powerful building blocks that we can use to make our code simpler and more comprehensible.
00:18:43.680 So let's revisit that code example from before, where we incorporated these advantages. You can observe how these simple methods easily chain together to create what I consider to be elegant code, and once you get the hang of it, it becomes easier to read and reason about compared to our initial convoluted version.
00:19:01.760 Sometimes, instead of nil, our methods can offer additional details regarding what went wrong. The Maybe monad represents a None, which leaves us with a lack of information about why it was nil.
00:19:17.120 In these situations, we could utilize the Result monad, which operates similarly to the Maybe monad, but replaces Some and None with Success and Failure. Success essentially functions the same as Some, encapsulating the value.
00:19:35.760 However, Failure can possess its own value, which can be a symbol, string, or any object, such as a hash, error message, or even an exception. Thus, we can amend 'find_user' to return a Result, where we updated it to yield a Failure with 'not_found' instead of None.
00:19:54.880 Since 'friendly_name' works with nil values, using a Maybe to wrap that is convenient. But we can also convert the Maybe into a Result using 'to_result,' ensuring the failure case consistently implements the same methods as Maybe.
00:20:13.920 This means we can use bind, fmap, and value or methods as before. Finally, we can leverage pattern matching to assess if we’ve succeeded or failed, understanding exactly how we failed and generating a corresponding message.
00:20:32.400 Now you can recognize how, with two distinct failure cases, we can discern between not being able to figure out the user’s preferred name and truly failing to find the user altogether, crafting different error messages for each case.
00:20:49.840 Returning to our simple transaction example from before our detour into the wondrous realm of monads, this should clarify things. Each transaction runs the steps in order as defined using the step DSL, articulated on lines four and five.
00:21:07.720 Each step must generate either a success or failure. If the outcome is a success, the value is passed as arguments to the next step; if it’s a failure, the transaction terminates, and that failure is returned from the transaction call.
00:21:30.000 Finally, we can take the result from the transaction call, whether it's a success or failure, and utilize Ruby's pattern matching on it to generate the message as we did when chaining together our two independent methods.
00:21:46.880 That's essentially what a transaction is—it’s a pretty DSL for seamlessly chaining together a series of methods, returning a Result. While writing out Success and Failure can become tedious and pull us away from Ruby's characteristic succinctness, Dry Transaction comes packaged with several wrappers called step adapters.
00:22:05.680 In fact, at TextUs, we rarely use the bare step method; we prefer these adapters instead and have even developed several of our own to address our needs.
00:22:19.440 We can use these adapters to streamline the execution of steps. The first is a 'try,' which returns a success if the initial method returns something and a failure if it raises the listed exception.
00:22:37.480 If it raises any other exception, that exception escapes the transaction as well. Since Active Record raises an exception if 'find' fails to find anything, we can catch that and turn it into a failure.
00:22:52.960 Next, the 'friendly_name' step now utilizes 'map,' which encapsulates whatever the method returns within a success. It’s also worth noting that when using the implicit failures from the step adapters, the value inside the failure is no longer explicitly supplied; it's inferred implicitly from the name of the step that failed.
00:23:15.840 So, whereas before 'find_user' failed with a 'not_found' error, now it produces a failure with the name 'find_user.'
00:23:28.560 Now, going back to our initial 'authenticate_user' transaction, there are a couple more steps here. The 'check' step only cares if the step returns something truthy or falsey; if it returns a truthy value, it passes the input straight through as the output.
00:23:49.680 You will see that both the 'valid_payload' and 'user' methods accept the same payload argument; if the method returns false, then the step indicates a failure. The 'T' step simply runs the method, discarding its return value while returning the original input as success.
00:24:07.360 This is useful in situations like notifying segment or appending the user to the Honeybadger error context.
00:24:25.360 Having used transactions full-time in our application for over three years now, I personally find this functional style to offer significant advantages. Notice the absence of if conditions, guard clauses, rescues, and early returns.
00:24:46.400 The code flow is very linear, and each step is isolated. If you're attempting to comprehend a section of code to fix a bug, you only need to read a couple of steps to grasp what's happening; you won’t have to bounce back and forth between files trying to make sense of the variables or helper methods in use.
00:25:05.519 At a high level, just by reviewing the step names at the top of the file, you can anticipate what's going to occur in this transaction without needing to explore each one unless necessary. If you do wish to delve deeper, you can effortlessly jump to any step method you find intriguing.
00:25:22.479 You can also ascertain the expected function of the code at a glance: is it producing side effects, checking conditions, or is it anticipating exceptions from this specific section?
00:25:40.559 Now that we understand what a Monad is and how transaction steps function, let’s explore what I believe is the most compelling aspect of transactions—error handling. Often, when writing code, we focus on the happy path.
00:25:58.800 Once that’s completed, we remember, 'Oh yes, this process can encounter errors.' I ought to guard against various nils and exceptions, and that becomes an afterthought. Some cases might even escape your attention until your code goes live.
00:26:19.240 Since every step in the transaction returns a result, the transaction handles this well. We can employ all the same functions, such as bind and fmap, as well as pattern matching to assess our transaction's result, checking if it's a failure and handling it appropriately.
00:26:37.280 Alternatively, we can continue executing processes like a typical authenticate controller helper. However, I've overlooked the most captivating part: managing actual failure.
00:26:53.880 When calling the transaction method, it also allows for an optional block that serves as a DSL for matching results, which can be success or failure. I prefer calling this block variable 'on' because it reads nicely.
00:27:10.160 In the case of a success, the unwrapped value encapsulated in the success is yielded to the block. We can perform that same operation of setting the current user as before.
00:27:26.560 If it fails, like in prior examples, the failure is identified through the step name that failed, and we can determine whether to carry out an alternate consequence based on that.
00:27:44.880 In this instance, we failed to decode the token for some reason, or perhaps the token lacked necessary data. We can respond with a 401 and include a response header for the browser to make the token authentication request.
00:28:09.120 If the token was valid but there was no corresponding user present, such as if they had been deleted or disabled, we merely respond with 'forbidden,' since requesting authentication again wouldn’t resolve the issue.
00:28:23.680 In our application at TextUs, we have a few controller actions that are simple enough to operate without involving transactions, mostly internal or utility endpoints. However, the vast majority of our controller actions handle some basic authorization using Pundit and immediately call a transaction to complete the actual work.
00:28:41.440 Nearly every transaction initiates with a 'validate' step, which we utilize via the Dry Schema and Dry Validation gems. This liberates us from dealing with permitted parameters from Rails, while also affording us much more granular validations, allowing type checks or data manipulation such as trimming white space from strings.
00:29:09.760 The transaction can additionally implement extra checks for specific conditions, such as observing for duplicate contacts. We can manage these cases with distinct error messages or simply choose to overlook them, treating them as successes.
00:29:28.080 The transaction can also execute various required operations, like queuing background jobs to update a search index or posting a webhook. We typically don't anticipate failures there, so we don’t handle these explicitly. Yet if something does go awry, that’s processed effectively.
00:29:44.240 If you haven't specified a step name for the failure branch, it will encompass all other failures, akin to the 'else' in a case statement. Another excellent aspect of the matching DSL is its strictness.
00:30:03.440 If you don’t address all potential values within the monad, the transaction will refuse to execute, triggering a non-exhaustive match error. Here, we have handled the success but not the failure.
00:30:12.640 Thus, the transaction raises an exception. Since you receive the exception, even if the transaction would typically return a success in a test case, it compels you to address all scenarios, minimizing the likelihood of any slipping through to your production environment.
00:30:22.080 I need to hurry. My other favorite aspect of transactions is how simple they make testing the transactions and the applications that utilize them.
00:30:37.680 Because each step is defined at the class level, it becomes known to the transaction and maintains a list of all the steps articulated in the DSL. This allows for overriding the implementation of a step during runtime.
00:30:55.200 You can employ dependency injection to supply a new implementation of any step to the transaction's initializer. In this example, we've swapped out the find user step (which executed the database lookup) with a lambda that just yields a failure.
00:31:12.080 This will compel the step to fail, which subsequently causes the transaction to fail at that step. While a bit contrived, it’s evident how this utility is advantageous, particularly when a step entails requests to a third-party service.
00:31:26.880 Without relying on mocks or VCR, we can directly supply the transaction with a specific step implementation and scrutinize that transaction’s behavior efficiently.
00:31:41.280 On line four, we’ve established some default arguments—in this case, an empty hash. Then on line seven, we initialized the transaction using those arguments, and on line eleven, we verified that the transaction yielded a success.
00:31:55.520 On line fifteen, we override the initial arguments with a hash that designs the step name we want to replace as a key, and a lambda to execute instead of that step. This lambda explicitly returns a failure, so our confirmatory test on line seventeen succeeds as a result of the failing step.
00:32:09.920 At TextUs, we utilize this method to provide diverse mock responses, whether valid or erroneous, from third-party integrations we interface with, particularly useful for testing cases such as timeouts or database errors which can be challenging to provoke when employing mocks or stubs.
00:32:26.560 In summary, Dry::Transaction also allows you to create custom step adapters to make them accessible to your transactions. The Dry::Transaction documentation discusses this more, so we're skipping that slide.
00:32:42.880 This illustrates how to create a step adapter that manages the operation, the step, and the input, returning either a success or failure. We have developed a few valuable adapters, one of which is a merge step.
00:32:59.760 We found it most effective for all our transactions to utilize keyword arguments for input and output parameters. Thus, our most commonly used step is this merge step. Below, we have two similar steps: one utilizing the built-in map and the other leveraging our custom merge step adapter.
00:33:19.760 To simplify understanding, I included a comment detailing the input keywords for each step. The 'user' step necessitates looking up the user in the database.
00:33:36.400 Since the map step recklessly returns the output of the preceding step to the next step if we wanted to use one of those keyword arguments in a subsequent step, they would be gone. Conversely, we capture the keyword arguments as a hash with double splat notation.
00:33:53.680 Consequently, as we locate the user, we merge that into the keyword arguments hash, returning that state. We found ourselves resorting to this repeatedly, so we derived that behavior into the merge step to reduce boilerplate.
00:34:10.720 You can observe how it’s applied in the metadata step following the user step. In this case, the input of the metadata step is the output from the previous user step. We have the user keyword argument to work with, while we can disregard the rest.
00:34:28.160 Next, we create something that will make an API call to retrieve some metadata and format it in a hash, and we’re done. The merge step efficiently manages the integration of that hash with the input hash under the key of metadata, which aligns with the step name.
00:34:44.240 Additionally, this step will not return success if something goes awry; rather, if it resolves in any manner of failure, it will yield that failure instead and stop the transaction.
00:35:03.280 Another process that we frequently execute is validating the inputs of the transaction. Referring back to an earlier example in the Rails controller, where the permitted parameters were completely overlooked and passed to 'unsafe h' directly to the transaction.
00:35:20.880 The transaction itself incorporated its own validation employing Dry Validation. I'm not going to delve into details on this, but we possess that unique validate class method facilitating inlining the Dry Validation DSL right there.
00:35:36.880 In addition to the previously discussed step adapters, we also maintain the 'maybe' and 'async' steps. Given we have numerous transactions, the use case arises when we might want to call another transaction from within this one. If that transaction fails, this one would also fail.
00:35:53.680 However, at times, you might not care if the transaction you’re trying to invoke fails. Thus, the 'maybe' step requires the transaction’s first step to be validation. If that validation fails, we simply skip it and continue.
00:36:13.680 But if validation succeeds, then the transaction is invoked, with the overall success or failure becoming the outcome of that step.
00:36:31.440 Finally, the 'async' step operates just like 'maybe,' except it utilizes Active Job to enqueue the entire transaction as a background job instead of executing it inline. This is excellent for back-end synchronization jobs.
00:36:48.320 Furthermore, it also performs the validation pre-check, meaning if there’s nothing to execute, it doesn’t incur any jobs.
00:37:06.720 That wraps up our discussion; I recognize that was pretty rapid and dense, and I barely scratched the surface. Yet, I hope I’ve stimulated a greater interest in learning about functional programming, monads, and Dry Transactions. Thank you!
Explore all talks recorded at RubyConf 2021
+95