4

I'm working on a large CMS system where a particular module and its submodules take advantage of the same backend API. The endpoint is exactly the same for each submodule aside from its "document type".

So a pattern like this is followed:

api/path/v1/{document-type}

api/path/v1/{document-type}/{id}

api/path/v1/{document-type}/{id}/versions

As time goes on the number of modules that use this API grows and I am left with many, many redundant api services that implement 7 CRUD methods:

getAllXs() {...}
getX(id) {...}
getXVersion(id, versionId) {...}

etc...

with an individual method looking like this

getAllXs() {
    let endpoint = BASE.URL + ENDPOINTS.X;
    let config = ...
    return http.get(endpoint, config)
        .then(response => response.data);
        .catch(...);

}

Where X would be the name of a particular Document Type.

I came to a point where I decided to make a single service and do something like this:

const BASE_URL = window.config.baseUrl + Const.API_ENDPOINT;
const ENDPOINTS = {
  "W": "/v1/W/",
  "X": "/v1/X/",
  "Y": "/v1/Y/",
  "Z": "/v1/Z/",
}

getAllDocuments(docType, config={}) {
  let endpoint = BASE_URL + ENDPOINTS[docType];
  return http.get(endpoint, config)
        .then(response => response.data);
        .catch(...);
}
...other methods

Where a type is specified and a mapped endpoint is used to build the path.

This reduces all of the document api services down to one. Now this is more concise code wise, but obviously now requires an extra parameter and the terminology is more generic:

getAllXs() --> getAllDocuments()

and it's a bit less 'idiot-proof'. What makes me insecure about the current way it is written is that there are 6 modules that use this API and the same 7 methods in each service.

The questions I keep asking myself are:

  • Am I bordering anti-pattern with the dynamic functions?

  • What if I had 10+ modules using the same API?

GHOST-34
  • 359
  • 1
  • 19

3 Answers3

6

Your question made me think of a common Object Relational Mapping design problem.

There are no single source of truth when it comes to design, but if your recognize an ORM in what you are building and value object oriented design principles, I have some inspiration for you.

Here's an over simplification of my own vanilla ES6 ORM I have used on many projects (reusing your code snippets for relatability). This deisgn is inspired by heavy ORM frameworks I have used in other languages.

class ORM {
   constructor() {
      this.BASEURL = window.config.baseUrl + Const.API_ENDPOINT
      this.config = {foo:bar} // default config
   }

   getAll() {
      let endpoint = this.BASEURL + this.ENDPOINT
      return http.get(endpoint, this.config)
       .then(response => response.data)
       .catch(...)
   }

   get(id) {
      // ...
   }
}

And examples of extension of that class (notice the one with a special configuration)

class XDocuments extends ORM {
   static endpoint = '/XDocument/'

   constuctor() {
      super()
   }

   otherMethod() {
      return 123
   }
}

class YDocuments extends ORM {
   static endpoint = '/YDocument/'

   constuctor() {
      super()
   }

   getAll() {
      this.config = {foo:not_bar}
      super.getAll()
   }
}

Since you are specifically asking if this is bordering anti-patterns. I would suggest reading about SOLID and DRY principles, and ORM designs in general. You will also find code smells about global constants, but this would only be true if you are in a window context. I see you are already on the right path trying to avoid code duplication smell and the shotgun surgery smell. :-)

Good luck and don't hesitate to ask further questions and add additional details in comments!

Kad
  • 639
  • 8
  • 25
  • I actually wrote a very similar answer without looking at yours, lol! I feel that this is the best way to not get tied into a "too dynamic" solution, that has no room for singular modifications – tcanusso Mar 09 '21 at 23:56
  • Hehe, 15 minutes too late!! ;-) I can appreciate this will give more confidence to the author who was looking for experience and validation. I included anti-patterns, design and smells article to add legitimacy to our similar approach. I hope it will help. :-) – Kad Mar 10 '21 at 00:00
  • 2
    See comment I left on @tcanusso answer. Yes, indeed this gives me confidence that I was heading in the right direction. My concern about anti-pattern was going with a single dynamic api service rather than an extendable base, but I realize now how fragile that would be. – GHOST-34 Mar 10 '21 at 00:42
1

If you'd provide more of your original version of code, that'd be more points to point to.

At the moment I just can say that the DRY principle is generally a good idea (not always but... well it's a complicated topic). There's a lot of articles about DRY. Google it.

You're afraid that your code became more complex. Don't be. In my experience, novice programmers fail miserably exactly because they discard the DRY principle. And only after a while, when they become stronger they start to fail at the KISS principle. And only one extra argument in addition to the 3 that already there doesn't add much to the complexity.

Am I bordering anti-pattern with the dynamic functions?

To be "dynamic" - that's the reason functions exist

What if I had 10+ modules using the same API?

Exactly! 10+ more chances to make a typo, to forget something, to misread, etc and 10+ more work to do if you'll need to change something. That is if you don't DRY your code.

PS. And the name getAllDocuments is actually better than getAllDocType1s if that's the real name from your original code.

x00
  • 13,643
  • 3
  • 16
  • 40
  • getAllResponses() would be an actual example... While I do abide by DRY as much as I can, I have suffered from making a function _too_ dynamic by adding additional parameters/flags to accommodate the ever growing requirements. This is why I'm torn here (an interface would be the perfect solution for this). The main reason I am leaning toward redundancy is because in some cases a particular document api service might require an additional header or param. A dynamic service would leave the component to provide that information which is not its job. – GHOST-34 Mar 03 '21 at 19:50
  • Edited answer with more details. – GHOST-34 Mar 03 '21 at 19:58
  • Yes, that's why I said "it's **generally** a good idea" :) but to make a right decision the full code and requirements and some times even the understanding of the whole project are needed. But in this particular case I see no reason for repetition: so you pass an extra optional argument from time to time - where is the **problem** with that? Where is the chance for a bug? – x00 Mar 03 '21 at 20:02
  • 1
    OK... Now I see... Yes, your new function looks more complicated than the original ones – x00 Mar 03 '21 at 20:03
  • And I have some question now: 1) why there's `if (Array.isArray(response.data))` 2) why do you need `then(data=>data)` 3) by the way, why don't you use `async/await` instead 4) why `toUpperCase` and not just uppercased `ENDPOINTS` 5) why don't you put ENDPOINTS in the `config` and if you use ENDPOINTS to map `docType` to urls you don't do the same for other config attributes instaed of passing them as args... hm...ther're more question, but I probably should stop :) – x00 Mar 03 '21 at 20:12
  • But I must say, while you've complicated things a bit, you've wrote quite a simple function still. It's way better than 10+ almost identical copies of it, for the reasons I've already mentioned (and more - `etc.` is quite long, actually) – x00 Mar 03 '21 at 20:18
  • And as you've mentioned `anti-patterns`... well, functions **without** arguments... I'd say it's a code smell. Again - not always. But it is in this case. – x00 Mar 03 '21 at 20:21
  • I made more edits. My bad, I mixed sample/generic code with some actual code and made it more confusing. I think your best point is #5 which might solve my problem. Basically, I could create a config file with definitions for all the configurations and just map to that. – GHOST-34 Mar 03 '21 at 20:44
  • After you've updated your question once more the difference in funcs' complexity is even less. And the only issue is if you'd call `getAllXs` twice you'll have twice a chance to make an error in config. For that, while my #5 point is a possible solution, I think there's even a better one: `const getAllXs = () => getAllDocuments("/v1/X/", { ... }); ...const getAllYs = () => getAllDocuments("/v1/Y/", { ... });` and no map needed. It's embedded in the funcs' definitions – x00 Mar 03 '21 at 21:16
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/229487/discussion-between-ghost-34-and-x00). – GHOST-34 Mar 04 '21 at 04:17
1

While I share most of x00's answer, I would take into account how static your endpoints really.

Is there no chance module "X" can change any of its endpoints definitions? For instance, you need to pass one more query param. Are your modules all exactly the same with no room for change?

If the answer is no, there you go. To make that simple change you would have to refactor your whole code base (if you would implement it the way you propose, that is).

If the answer is yes, well, I see no reason for you to not implement your proposed dynamic functions. Personally, I would lean towards a service that my modules extend and use, just in case I do want to make minimal changes to them. For instance:

class MyGenericService {
  constructor() {
    this.url = window.config.baseUrl;
  }

  async getAllDocuments(config) {
    return http.get(this.url, config)
      .then(response => response.data);
      .catch(...);
  };

  // ...and so on
}

This allows my code to scale and be modifiable, with just one takeaway which is you would need to maintain one file per module, that has something like this:

class XService extends MyGenericService {
  constructor() {
    this.url = window.config.baseUrl + '/v1/x';
  }
}

If maintaining this extra files is too much overhead, you could receive the endpoint's URL in the constructor, on the MyGenericService, and you would just need to do stuff like this in your controllers:

const myXService = new MyGenericService('/v1/x');
const myYService = new MyGenericService('/v1/y');
// ...or it could use your endpoint url mapping
// I don't really know how is your code structured, just giving you ideas

There you have some options, hope it helps!

tcanusso
  • 192
  • 7
  • Have you considered using static attributes in the inherited class? If you have - why not? I find the repetition of "window.config.baseUrl" should be own by the parent class to avoid the "Shotgun Surgery" smell. – Kad Mar 10 '21 at 00:07
  • 1
    I actually coded first an option where the generic service received the endpoint via constructor params, then changed it so I left it like this. I actually like more your way, so there's no need to repeat the constant "window.bla" which shouldn't change – tcanusso Mar 10 '21 at 00:11
  • So I actually moved to this pattern after a few api services. It was after I created that base class that I started to question "Can it essentially _just_ be the base class?" which is why I posted here. I ended up going with that pattern but using ES6 modules over classes. Problem with a single dynamic service is that only one specific requirement needs to come along for a particular endpoint for it to break or be forced to become even _more_ dynamic. The redundancy feels a little extra redundant at the moment, but I think I'll thank myself later. – GHOST-34 Mar 10 '21 at 00:36