Mancata corrispondenza concettuale tra DDD Application Services e l'API REST


20

Sto cercando di progettare un'applicazione con un dominio aziendale complesso e un requisito per supportare un'API REST (non strettamente REST, ma orientata alle risorse). Ho dei problemi a trovare un modo per esporre il modello di dominio in modo orientato alle risorse.

In DDD, i clienti di un modello di dominio devono passare attraverso il livello procedurale "Servizi applicativi" per accedere a qualsiasi funzionalità aziendale, implementata da Entità e Servizi di dominio. Ad esempio, esiste un servizio applicativo con due metodi per aggiornare un'entità utente:

userService.ChangeName(name);
userService.ChangeEmail(email);

L'API di questo servizio applicazioni espone comandi (verbi, procedure), non stato.

Ma se dobbiamo anche fornire un'API RESTful per la stessa applicazione, esiste un modello di risorsa utente, simile al seguente:

{
name:"name",
email:"email@mail.com"
}

L'API orientata alle risorse espone lo stato , non i comandi . Ciò solleva le seguenti preoccupazioni:

  • ogni operazione di aggiornamento su un'API REST può essere associata a una o più chiamate di procedura del servizio applicazione, a seconda delle proprietà che vengono aggiornate sul modello di risorsa

  • ogni operazione di aggiornamento sembra atomica al client API REST, ma non è implementata in questo modo. Ogni chiamata al servizio applicazione è progettata come una transazione separata. L'aggiornamento di un campo su un modello di risorsa potrebbe modificare le regole di convalida per altri campi. Pertanto, è necessario convalidare tutti i campi del modello di risorsa per garantire che tutte le potenziali chiamate al servizio applicazione siano valide prima di iniziare a effettuarle. Convalidare una serie di comandi contemporaneamente è molto meno banale che eseguirne uno alla volta. Come possiamo farlo su un client che non sa nemmeno che esistono singoli comandi?

  • la chiamata di metodi del servizio applicazione in ordine diverso potrebbe avere un effetto diverso, mentre l'API REST fa sembrare che non ci siano differenze (all'interno di una risorsa)

Potrei trovare problemi più simili, ma fondamentalmente sono tutti causati dalla stessa cosa. Dopo ogni chiamata a un servizio applicativo, lo stato del sistema cambia. Regole di ciò che è un cambiamento valido, l'insieme di azioni che un'entità può eseguire il prossimo cambiamento. Un'API orientata alle risorse cerca di far sembrare tutto un'operazione atomica. Ma la complessità di attraversare questo divario deve andare da qualche parte, e sembra enorme.

Inoltre, se l'interfaccia utente è più orientata ai comandi, come spesso accade, allora dovremo mappare tra comandi e risorse sul lato client e poi di nuovo sul lato API.

Domande:

  1. Tutta questa complessità dovrebbe essere gestita solo da un (spesso) livello di mappatura da REST ad AppService?
  2. O mi manca qualcosa nella mia comprensione di DDD / REST?
  3. REST potrebbe semplicemente non essere pratico per esporre la funzionalità dei modelli di dominio su un certo grado (piuttosto basso) di complessità?


Pensa al client REST come a un utente del sistema. A loro non importa assolutamente nulla di COME il sistema esegue le azioni che esegue. Non ti aspetteresti più che il client REST conosca tutte le diverse azioni sul dominio di quanto ti aspetteresti da un utente. Come dici tu, questa logica deve andare da qualche parte, ma dovrebbe andare da qualche parte in qualsiasi sistema, se non stavi usando REST, la sposteresti nel client. Non farlo è proprio il punto di REST, il client dovrebbe sapere solo che vuole aggiornare lo stato e non dovrebbe avere idea di come procedere.
Cormac Mulhall,

2
@astr La semplice risposta è che le risorse non sono il tuo modello, quindi la progettazione del codice di gestione delle risorse non dovrebbe influenzare la progettazione del tuo modello. Le risorse sono un aspetto esteriore del sistema, in cui il modello è interno. Pensa alle risorse nello stesso modo in cui potresti pensare all'interfaccia utente. Un utente può fare clic su un singolo pulsante sull'interfaccia utente e nel modello accadono centinaia di cose diverse. Simile a una risorsa. Un client aggiorna una risorsa (una singola istruzione PUT) e nel modello potrebbero accadere un milione di cose diverse. È un anti-pattern per abbinare il tuo modello strettamente alle tue risorse.
Cormac Mulhall,

1
Questa è una buona chiacchierata sul trattamento delle azioni nel tuo dominio come effetti collaterali dei cambiamenti dello stato REST, mantenendo separati il ​​tuo dominio e il web ( vai
Cormac Mulhall,

1
Inoltre, non sono sicuro dell'intera cosa "utente come robot / macchina a stati". Penso che dovremmo cercare di rendere le nostre interfacce utente molto più naturali di così ...
guillaume31

Risposte:


10

Ho avuto lo stesso problema e "risolto" modellando le risorse REST in modo diverso, ad esempio:

/users/1  (contains basic user attributes) 
/users/1/email 
/users/1/activation 
/users/1/address

Quindi ho sostanzialmente diviso la risorsa più grande e complessa in più risorse più piccole. Ognuno di questi contiene un gruppo in qualche modo coerente di attributi della risorsa originale che dovrebbe essere elaborato insieme.

Ogni operazione su queste risorse è atomica, anche se può essere implementata utilizzando diversi metodi di servizio - almeno in Spring / Java EE non è un problema creare transazioni più grandi da diversi metodi che originariamente intendevano avere una propria transazione (usando la transazione RICHIESTA propagazione). Spesso è ancora necessario eseguire una convalida aggiuntiva per questa risorsa speciale, ma è ancora abbastanza gestibile poiché gli attributi sono (si suppone siano) coerenti.

Questo vale anche per l'approccio HATEOAS, perché le tue risorse più dettagliate trasmettono più informazioni su cosa puoi fare con loro (invece di avere questa logica su client e server perché non può essere facilmente rappresentata nelle risorse).

Naturalmente non è perfetto - se l'interfaccia utente non è modellata tenendo a mente queste risorse (in particolare le UI orientate ai dati), può creare alcuni problemi - ad esempio l'interfaccia utente presenta una grande forma di tutti gli attributi di determinate risorse (e le sue risorse secondarie) e ti consente di modificali tutti e salvali in una volta - questo crea l'illusione dell'atomicità anche se il cliente deve chiamare diverse operazioni di risorse (che sono esse stesse atomiche ma l'intera sequenza non è atomica).

Inoltre, questa divisione delle risorse a volte non è facile o ovvia. Lo faccio principalmente su risorse con comportamenti / cicli di vita complessi per gestirne la complessità.


È quello che ho pensato anche: creare rappresentazioni di risorse più granulari perché sono più convenienti per le operazioni di scrittura. Come gestite l'interrogazione delle risorse quando diventano così granulari? Creare anche rappresentazioni de-normalizzate di sola lettura?
astreltsov,

1
No, non ho rappresentazioni de-normalizzate di sola lettura. Uso lo standard jsonapi.org e ha un meccanismo per includere risorse correlate nella risposta per una determinata risorsa. Fondamentalmente dico "dammi Utente con ID 1 e includo anche la sua e-mail e l'attivazione di risorse secondarie". Questo aiuta a sbarazzarsi delle chiamate REST extra per le risorse secondarie e non influisce sulla complessità del client che gestisce le risorse secondarie se si utilizza una buona libreria client API JSON.
qbd,

Quindi una singola richiesta GET sul server si traduce in una o più query effettive (a seconda di quante risorse secondarie sono incluse) che vengono poi combinate in un singolo oggetto risorsa?
astreltsov,

Cosa succede se è necessario più di un livello di annidamento?
astreltsov,

Sì, nei dbs relazionali questo probabilmente si tradurrà in più query. La nidificazione arbitraria è supportata dall'API JSON, è descritta qui: jsonapi.org/format/#fetching-includes
qbd

0

Il problema chiave qui è, come viene invocata in modo trasparente la logica aziendale quando viene effettuata una chiamata REST? Questo è un problema che non viene risolto direttamente da REST.

Ho risolto questo problema creando il mio livello di gestione dei dati su un provider di persistenza come JPA. Utilizzando un metamodello con annotazioni personalizzate, possiamo invocare la logica aziendale appropriata quando cambia lo stato dell'entità. Ciò garantisce che, indipendentemente da come lo stato dell'entità cambia, viene invocata la logica aziendale. Mantiene la tua architettura ASCIUTTA e anche la tua logica aziendale in un unico posto.

Utilizzando l'esempio sopra, possiamo invocare un metodo di logica aziendale chiamato validateName quando il campo del nome viene modificato utilizzando REST:

class User { 
      String name;
      String email;

      /**
       * This method will be transparently invoked when the value of name is changed
       * by REST.
       * The XorUpdate annotation becomes effective for PUT/POST actions
       */
      @XorPostChange
      public void validateName() {
        if(name == null) {
          throw new IllegalStateException("Name cannot be set as null");
        }
      }
    }

Con tale strumento a tua disposizione, tutto ciò che dovrai fare è annotare i metodi della tua logica aziendale in modo appropriato.


0

Ho dei problemi a trovare un modo per esporre il modello di dominio in modo orientato alle risorse.

Non dovresti esporre il modello di dominio in modo orientato alle risorse. Dovresti esporre l'applicazione in modo orientato alle risorse.

se l'interfaccia utente è più orientata ai comandi, come spesso accade, allora dovremo mappare tra comandi e risorse sul lato client e poi di nuovo sul lato API.

Niente affatto: invia i comandi alle risorse dell'applicazione che si interfacciano con il modello di dominio.

ogni operazione di aggiornamento su un'API REST può essere associata a una o più chiamate di procedura del servizio applicazione, a seconda delle proprietà che vengono aggiornate sul modello di risorsa

Sì, anche se esiste un modo leggermente diverso di scrivere ciò che potrebbe rendere le cose più semplici; ogni operazione di aggiornamento su un'API REST è mappata a un processo che invia comandi a uno o più aggregati.

ogni operazione di aggiornamento sembra atomica al client API REST, ma non è implementata in questo modo. Ogni chiamata al servizio applicazione è progettata come una transazione separata. L'aggiornamento di un campo su un modello di risorsa potrebbe modificare le regole di convalida per altri campi. Pertanto, è necessario convalidare tutti i campi del modello di risorsa per garantire che tutte le potenziali chiamate al servizio applicazione siano valide prima di iniziare a effettuarle. Convalidare una serie di comandi contemporaneamente è molto meno banale che eseguirne uno alla volta. Come possiamo farlo su un client che non sa nemmeno che esistono singoli comandi?

Stai inseguendo la coda sbagliata qui.

Immagina: togli completamente REST dall'immagine. Immagina invece di scrivere un'interfaccia desktop per questa applicazione. Immaginiamo inoltre di avere requisiti di progettazione davvero validi e di implementare un'interfaccia utente basata su attività. In questo modo l'utente ottiene un'interfaccia minimalista perfettamente adattata all'attività che sta svolgendo; l'utente specifica alcuni input quindi preme "VERB!" pulsante.

Che succede ora? Dal punto di vista dell'utente, questo è un singolo compito atomico da svolgere. Dal punto di vista del domainModel, è un numero di comandi eseguiti da aggregati, in cui ciascun comando viene eseguito in una transazione separata. Quelli sono completamente incompatibili! Abbiamo bisogno di qualcosa nel mezzo per colmare il divario!

Il qualcosa è "l'applicazione".

Sul percorso felice, l'applicazione riceve alcuni DTO e analizza quell'oggetto per ottenere un messaggio che comprende e utilizza i dati nel messaggio per creare comandi ben formati per uno o più aggregati. L'applicazione si assicurerà che ciascuno dei comandi che invia agli aggregati sia ben formato (questo è il livello anticorruzione al lavoro) e caricherà gli aggregati e salverà gli aggregati se la transazione viene completata correttamente. L'aggregato deciderà da solo se il comando è valido, dato il suo stato attuale.

Possibili risultati - tutti i comandi vengono eseguiti correttamente - Il livello anticorruzione rifiuta il messaggio - Alcuni comandi vengono eseguiti correttamente, ma uno degli aggregati si lamenta e si ha una contingenza da mitigare.

Ora, immagina di avere quell'applicazione costruita; come interagisci con esso in modo RESTful?

  1. Il client inizia con una descrizione ipermediale del suo stato corrente (ovvero: l'interfaccia utente basata sull'attività), inclusi i controlli ipermediali.
  2. Il client invia una rappresentazione dell'attività (ovvero: il DTO) alla risorsa.
  3. La risorsa analizza la richiesta HTTP in entrata, acquisisce la rappresentazione e la passa all'applicazione.
  4. L'applicazione esegue l'attività; dal punto di vista della risorsa, questa è una scatola nera che ha uno dei seguenti risultati
    • l'applicazione ha aggiornato correttamente tutti gli aggregati: la risorsa segnala il successo al client, indirizzandolo a un nuovo stato dell'applicazione
    • il livello anticorruzione rifiuta il messaggio: la risorsa segnala un errore 4xx al client (probabilmente Bad Request), passando eventualmente una descrizione del problema riscontrato.
    • l'applicazione aggiorna alcuni aggregati: la risorsa segnala al client che il comando è stato accettato e indirizza il client a una risorsa che fornirà una rappresentazione dell'avanzamento del comando.

Accettato è il solito cop-out quando l'applicazione sta per posticipare l'elaborazione di un messaggio fino a dopo aver risposto al client, comunemente usato quando si accetta un comando asincrono. Ma funziona anche bene in questo caso, in cui un'operazione che dovrebbe essere atomica necessita di mitigazione.

In questo linguaggio, la risorsa rappresenta l'attività stessa: si avvia una nuova istanza dell'attività pubblicando la rappresentazione appropriata sulla risorsa dell'attività e tale risorsa si interfaccia con l'applicazione e indirizza l'utente al successivo stato dell'applicazione.

In , praticamente ogni volta che stai coordinando più comandi, vuoi pensare in termini di un processo (aka business process, aka saga).

C'è una discrepanza concettuale simile nel modello letto. Ancora una volta, considera l'interfaccia basata su attività; se l'attività richiede la modifica di più aggregati, probabilmente l'interfaccia utente per la preparazione dell'attività include i dati di un numero di aggregati. Se lo schema delle risorse è 1: 1 con aggregati, sarà difficile da organizzare; fornire invece una risorsa che restituisca una rappresentazione dei dati da diversi aggregati, insieme a un controllo ipermediale che associ la relazione "attività iniziale" all'endpoint dell'attività come discusso sopra.

Vedi anche: REST in Practice di Jim Webber.


Se stiamo progettando l'API per interagire con il nostro dominio secondo i nostri casi d'uso. Perché non progettare le cose in modo tale che Sagas non sia affatto richiesto? Forse mi manca qualcosa, ma leggendo la tua risposta credo davvero che REST non sia una buona corrispondenza con DDD ed è meglio usare le procedure remote (RPC). DDD è incentrato sul comportamento mentre REST è incentrato sul verbo http. Perché non rimuovere REST dall'immagine ed esporre il comportamento (comandi) nell'API? Dopotutto, probabilmente sono stati progettati per soddisfare scenari di casi d'uso e prob sono transazionali. Qual è il vantaggio di REST se possediamo l'interfaccia utente?
iberodev il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.