Blog

Using design patterns in AWS Lambda

15 Jan, 2024
Xebia Background Header Wave

When you speak with software developers, they will probably tell you that they use design patterns. But when the world first shifted to the internet the general feeling was that these design patterns would not work for the web. This is not true, and today you see these patterns being used more and more.

I have noticed the same behavior with serverless. In this blog post I will go over some reasons why you should be using design patterns in your Lambda functions

Getting started

To get started with AWS Lambda is quite easy, and this is also the reason why some crucial steps are skipped. For example, you can use the console to create a function and type your code in an editor via your browser. Or, you create a CloudFormation template and you put your code in the template itself. Obviously, this is fine for experimentation and learning purposes. But when you start developing systems that need to be reliable you need to take a different approach.

The biggest objection that I have with the editor in the console is that it does not allow you to run tests. Also using inline code that is part of a CloudFormation template has some downsides. Again you cannot run tests against your inline code and the code becomes vulnerable to indenting and syntax errors due to the lack of proper IDE highlighting.

Both options do not allow you to add dependencies, or at least not in an easy way. So you default to the installed dependencies, and you have no control over these dependencies.

Separate your infrastructure and application code

By storing your application code in separate files you will have all the benefits of using an IDE. You can run linters, formatters and tests and you have syntax highlighting while you develop your code. This also allows you to run these steps in the CI/CD pipeline before you actually deploy your code to production. This gives you a quality gate and a decision moment to say yes this can go to production.

When you combine this with the AWS Serverless Application Model you can also very easily include your dependencies. Or use a compiled language like golang for your Lambda functions. You simply run sam build before you run the aws cloudformation package and aws cloudformation deploy commands. SAM will build the binary and update the template to point to the newly built binary. Package will then upload it to S3 and replace the local reference to the S3 location. Deploy can then create or update the stack or you can use the CloudFormation integration in CodePipeline.

Why should I use design patterns

Testing AWS calls can be challenging, I wrote a blog on this for golang. By splitting the business logic from the infrastructure in your code you gain 2 important things:

  • You can focus on the business logic without having to deal with stubbing your infrastructure.
  • The used infrastructure can be changed without the need to change your business logic.

The former makes it easier to add test scenarios. When it’s easy to add scenarios you are more likely to add more scenarios. Increasing the reliability of your business logic. The latter gives you flexibility, you can swap a RDS database with a DynamoDB table relatively easily. Without changing the business logic, this also means that you have less of a vendor lock-in.

Golang example

Let’s use the Observer pattern, first you will need some interfaces:

type (
   Event struct {
      name string
   }

   Observer interface {
      NotifyCallback(Event)
   }

   Subject interface {
      AddListener(Observer)
      RemoveListener(Observer)
      Notify(Event)
   }

   eventObserver struct {
      id   int
      time time.Time
   }

   eventSubject struct {
      observers sync.Map
   }
)

In this example we define an Event, Subject and an Observer. We can use the event to store all the relevant data. When you pass the Event in the Subject the Event is sent to all the registered observers.

Next we need some logic to register the observers on the Subject:

func (s *eventSubject) AddListener(observer Observer) {
   s.observers.Store(observer, struct{}{})
}

func (s *eventSubject) RemoveListener(observer Observer) {
   s.observers.Delete(observer)
}

func (s *eventSubject) Notify(event Event) {
   s.observers.Range(func(key interface{}, value interface{}) bool {
      if key == nil || value == nil {
         return false
      }


      key.(Observer).NotifyCallback(event)
      return true
   })
}

As you can see the Notify method will iterate over all observers and pass the event to all registered observers. The observer could be as simple as:

func (e *eventObserver) NotifyCallback(event Event) {
   fmt.Printf("Received from observer %d: %s after %v\n", e.id, event.name, time.Since(e.time))
}

For your tests you can simply implement a testObserver:

type testObserver struct {
   event Event
}

func (e *testObserver) NotifyCallback(event Event) {
   e.event = event
}

This is a very simplified version of the business logic:

func doStuff(observer Observer) {
   n := eventSubject{observers: sync.Map{}}
   n.AddListener(observer)
   n.Notify(Event{name: "Joris Conijn"})
}

We create an Event with Joris Conijn as the name value. We want to test this business logic that it indeed creates this Event with Joris Conijn as the name value.

func TestObserver(t *testing.T) {
   t.Run("Run doStuff", func(t *testing.T) {
      var obs1 = testObserver{}
      doStuff(&obs1)
      assert.Equal(t, obs1.event.name, "Joris Conijn")
   })
}

For the actual implementation the observer would store it in DynamoDB or RDS. To have a good testing coverage you do need to test the observer. Per observer you now test the actual implementation of the database of your choice.

Conclusion

You can use design patterns in your Lambda functions. It is a great way to separate your business logic from the used infrastructure. And it makes you business logic easier to test. However, you might have noticed that you will need some boilerplate code to implement these patterns.

My advice would be to use these patterns when you need portability and/or when you have more complex business logic. For very simple Lambda functions it might not be worth it.

Thanks Tensor Programming for the inspiration. Photo by Soloman Soh

Joris Conijn
Joris has been working with the AWS cloud since 2009 and focussing on building event driven architectures. While working with the cloud from (almost) the start he has seen most of the services being launched. Joris strongly believes in automation and infrastructure as code and is open to learn new things and experiment with them, because that is the way to learn and grow. In his spare time he enjoys running and runs a small micro brewery from his home.
Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts