Three Approaches for Organizing your AdonisJS Business Logic Operations

In this lesson, we'll dive deep into three different ways we can organize our code; fat controllers, services, and actions. We'll also discuss circular dependencies, static and non-static service methods, and dependency injection.

Published
Jul 22, 24
Duration
28m 8s

Developer & dog lover. I teach AdonisJS, a full-featured Node.js framework, at Adocasts where I publish weekly lessons. Professionally, I work with JavaScript, .NET, and SQL Server.

Adocasts

Burlington, KY

Get the Code

Download or explore the source code for this lesson on GitHub

Repository

🕰️ Chapters
00:00 - Objectives & Project Familiarity
01:02 - What Are Fat Controllers?
02:43 - Fat Controllers & Services with Static Methods
04:08 - Completing Our Fat Controller Store Method
06:28 - Testing Our Store Method
07:50 - Fat Controller Pros/Cons
08:34 - Switching To Services
08:49 - Non-Static Service Methods
09:52 - Services & Dependency Injection (DI)
11:30 - The Service Approach
14:20 - Completing Our Service Store Method
16:20 - Splitting Out Sub-Tasks with Services
17:08 - Testing Our Service Store Method
17:23 - Service Approach Pros/Cons
17:53 - Services & Circular Dependencies
20:15 - Switching To Actions
20:30 - What Is An Action?
21:55 - The Action Approach
23:34 - Completing Our Action Store Class
24:42 - Splitting Out Sub-Tasks with Actions
25:55 - Testing Our Action Store Class
26:48 - Action Approach Pros/Cons
27:17 - Wrapping Up & Other Potential Options

Join The Discussion! (2 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Commented 1 month ago

    Hey thanks for the tips. One question though, what would be your approach to avoid over-fetching data from DB, since the queries are hardcoded (inside services and actions). For example one view or endpoint could only require one field from DB, and another could require more. Would you accept params in the action to modify the query or would create more specific actions to adapt the query?

    1

    Please sign in or sign up for free to reply

    1. Commented 1 month ago

      Hi Diego! That's a great question! My approach would vary depending on just how segmented I would need to get at the data. Let's take a Post model as an example. A post might have certain types (blog, article, news, etc). If the type is the primary way I need to fetch the post, I might have a getPostsByTypeId(typeId: number) service method or action.

      However, typically there are other checks needed for a post as well

      • Fetching only those that are published, not published, or all

      • Fetching posts tied to a specific taxonomy/topic

      • Ordering by publication date, title, or something else

      • Should we paginate the results or get only a specific number back

      And, the list could go on from there. Our endpoints will vary from caring about none through all of these.

      My approach to this is to stop and think about what most endpoints are going to need now or shortly in the future. I don't worry about issues we might have 3 or 5 years down the road, as those may or may not come to fruition and we can always refactor to appease them if they do.

      I'd then create methods (service or action) to satisfy the requirements of most of the endpoints I'll need. This might result in:

      • getPosts(stateId: number, typeId: number, limit?: number = 10, orderBy: PostSorts = 'latest')
        This would return the first limit number of published posts for the provided type and state ordered by sorts we have defined for our posts (like latest, alphabetical, popular, etc). Type would be whether it's a blog, article, etc and state would determine if it's published or not.

      • getPostsPaginated(typeId: number, pagination: PaginatorQuery, orderBy: PostSorts = 'latest')
        This would be similar to getPosts, but would return a paginator instead of the first limit. PaginatorQuery here would contain the page, perPage, and baseUrl

      That alone would satisfy most of the way we would need to get at our posts, however, a really nice part about Lucid's query builder is that the query can be built on top of further until it is either awaited, executed, or we call a method that does not return back the builder, like paginate.

      Meaning, we could return the Post Query Builder from these methods giving us the ability to expand off of them as needed, which is really convenient for those outliers. We might also choose to call these queryPosts instead of get to note that they're expandable. For example:

      export default class QueryPosts {
        static handle(
          stateId: number, 
          typeId: number, 
          limit?: number = 10, 
          orderBy: PostSorts = 'latest'
        ): ModelQueryBuilderContact<typeof Post, Post> {
          const query = await Post.query()
            .where({ stateId })
            .where({ typeId })
            .limit(limit)
      
          switch (orderBy) {
            case PostSorts.ALPHABETICAL:
              query.orderBy('title', 'asc')
              break
            default:
              query.orderBy('publishAt', 'desc')
              break
          }
      
          return query
        }
      }
      
      const query = QueryPosts.handle(States.PUBLISHED, typeId: PostTypes.BLOG)
      const blogs = await query
        .whereHas('topics', (topic) => topic.where('topics.slug', slug))
      Copied!

      Now, with this in mind and also that paginate won't allow this, we could remove the limit parameter from this method, get rid of our getPostsPaginated method and instead perform those outside of these methods for additional expansiveness.

      If desired, we can create additional methods that call and build off our getPosts as well. Like, the above blog query could be wrapped in a getPostsByTopic(slug: string) method. This comes in handy if this is a frequent way we're going to get at our posts. If we're only ever going to need this for a single page, we might chose to leave the query as-is from the above previous example.

      I also really like giving the more complex statements in my queries names. This can be done via query scopes or you can go a step further and create a builder. A builder is essentially a query builder wrapping the Model Query Builder itself with chainable methods specific to your application. That might look like the below.

      export default class PostBuilder extends BaseBuilder<typeof Post, Post> {
        whereHasTopic(slug: string) {
          this.query.whereHas((query) => query.where('topics.slug', slug)
          return this
        }
      
        // use ids
        whereTypeId(typeId: PostTypes) {
          this.query.where({ typeId })
          return this
        }
      
        // or use named methods
        wherePublished() {
          this.query.where('stateId', States.PUBLISHED)
          return this
        }
      
        whereNotPublished() {
          this.query.whereNot('stateId', States.PUBLISHED)
          return this
        }
      
        // or offer both
        whereStateId(stateId: States) {
          this.query.where({ stateId })
          return this
        }
      
        orderLatest() {
          this.query.orderBy('publishAt', 'desc')
          return this
        }
      }
      
      const blogs = await new PostBuilder()
        .whereHasTopic('adonisjs')
        .wherePublished()
        .whereTypeId(PostTypes.BLOG)
        .orderLatest()
      Copied!

      What I like about this is that it keeps our queries very easy to build and understand. It also keeps any methods we may choose to wrap this with small and easily reusable as well. Which, in turn, makes it less of a chore to have multiple different methods for getting at our posts. This is actually the approach I've used for the Adocasts site, if you'd like to see a full example.

      Sorry this was a long answer, TLDR: the best approach will vary depending on how many varying ways you need to query your data. If it's only a few ways, specific methods will do just fine. If you need it a myriad of different ways, more complex or even a combination of solutions may prove fruitful.

      0

      Please sign in or sign up for free to reply