Questa domanda mi è venuta quando stavo provando a implementare Clean Architecture usando Scala, e ho trovato questo posta .
Nella risposta accettata, @candiedorange enfatizza la separazione delle responsabilità e non infrange la regola "Dì, non chiedere".
Ma questo mi confonde ancora.
Per quanto ho ricordato, la regola "Tell, do not ask" non indicava che non dovremmo trasmettere i dati di ritorno a qualcosa. Semplicemente afferma che il chiamante non dovrebbe prendere decisioni sugli stati interni di alcuni oggetti.
Ad esempio, in Scala o nella programmazione funzionale, spesso chiamiamo una funzione per ottenere qualcosa e passarla a un'altra funzione per ottenere il risultato che vogliamo.
Ad esempio, se vogliamo calcolare la somma dei quadrati di tutti i numeri pari all'interno di un elenco, molto probabilmente faremo quanto segue:
def sumList(xs: List[Int]) = ???
val xs = List(1, 2, 3, 4, 5, 6, 7, 9, 0)
val evenList = xs.filer(_ % 2 == 0)
val squares = evenList.map(_ * _)
val sum = sumList(evenList)
Questo ha infranto la regola "Tell, do not ask"? In caso contrario, perché creare un relatore e passare i dati di ritorno dal caso d'uso per ottenere un ViewModel infrange questa regola?
Mentre cerco di capire quale sia il miglior design, ho creato tre versioni del mio UseCase. Mi piacerebbe avere un'opinione su quale sia il design migliore.
Versione A. Unità di restituzione del presentatore (void).
Il primo è proprio come la soluzione proposta in quel post, e questo codice . Passo il presentatore all'oggetto use case e l'interfaccia del presenter non restituisce nulla.
IMHO, questo non è davvero OK. In Scala, proviamo a minimizzare la mutevolezza e gli effetti collaterali. Ma in questo caso, Presenter deve avere effetti collaterali all'interno di se stesso, o sarà totalmente inutile.
trait Presenter[T] {
def onResult(value: Try[T]): Unit
}
class GetOrderUseCase(request: Request) {
def execute(presenter: Presenter[Order]): Unit = {
val result = Try {
// doing something with request...etc.
}
presenter.onResult(result)
}
}
Ho abbandonato questa soluzione rapidamente, il nostro presentatore deve avere qualche tipo di effetto collaterale. E ci vorrà molto sforzo per testare questo caso d'uso. Devo fornire un presentatore che non faccia nulla di significativo (ma abbia ancora effetti collaterali, o non riuscirei a recuperare il risultato da usecase) al caso d'uso.
Questa soluzione impedisce anche il riutilizzo del caso d'uso. Cosa succede se ho bisogno di informazioni che combino due casi d'uso per rendere una visualizzazione? Sarà ancora fattibile, ma non molto conveniente.
Versione B. Presentatore restituisce qualcosa.
Mi sono subito reso conto se accettavo che il presentatore avesse un valore di ritorno. Potrei creare un presentatore senza alcun effetto collaterale. Ancora meglio, potrei non aver bisogno di creare un'interfaccia. Potrei semplicemente usare l'oggetto funzione Scala.
Quindi ecco la mia seconda versione:
type Presenter[T, R] = Function1[Try[T], R]
class GetOrderUseCase(request: Request) {
def execute[R](presenter: Presenter[Order, R]): R = {
// doing something with request...etc.
val result: Try[Order] = Try { ??? }
presenter(result)
}
}
// When test
val useCase = GetOrderUseCase(request)
val orderStatus = useCase.execute { order => order.status }
orderId shouldBe JustCreated
Non male, ma potrei ancora capire perché dovremmo passare il presenter a usecase, e non solo restituire semplici dati dall'oggetto usecase.
L'unico vantaggio di questa soluzione a cui posso pensare è che nell'API chiediamo esplicitamente a un Presenter di presentare il caso d'uso.
Ma non sono ancora convinto che questo sia l'approccio giusto. Perché? Vediamo cosa accadrà se lasciamo utilizzare i dati di ritorno del caso.
Versione C. UseCase restituisce qualcosa.
class ViewModel
trait OrderPresenter {
def present(data: Try[Order]): ViewModel
}
object GUIOrderPresenter extends OrderPresenter {
def present(data: Try[Order]): ViewModel = {
// Convert to ViewModel etc...
}
}
class GetOrderUseCase(request: Request) {
def execute(): Try[Order] = Try { ??? }
}
// On controller
val useCase = new GetOrderUseCase(request)
val viewModel = GUIOrderPresenter(useCase.execute)
render(viewModel)
Sembra una soluzione molto migliore per me. E ancora meglio, dato che il nostro caso d'uso restituisce una Prova [T], potremmo combinare più casi d'uso per for
parole chiave fornite da Scala.
val orderInfoUseCase = new GetOrderUseCase(request)
val shippingInfoUseCase = new ShippingInfo(request)
val viewModel =
for {
orderInfo <- orderInfoUseCase.execute
shippingInfo <- shippingInfoUseCase.execute
} yield SomePresenter(orderInfo, shippingInfo)
render(viewModel.recoverWith(SomePresenter.onError))
Domande
Finalmente, ecco la mia domanda:
-
La mia implementazione della terza versione interrompe la regola "Tell, do not ask"?
Direi di no. Nella maggior parte dei casi, sarà solo un caso d'uso, e tutto ciò che il mio controller sta facendo è una funzione stupida.
-
Dos la mia implementazione della terza versione infrange il principio CQRS?
Non direi né l'uno né l'altro. In questo caso, il nostro caso d'uso semplicemente restituendo dati, non ha modificato nessuno stato nel sistema. Se voglio davvero seguire il CQRS, allora mi assicurerò che l'uso di un caso con un effetto collaterale non dovrebbe restituire nulla.
-
Il mio controller segue il modello di oggetto umile? Credo di si. È così banale che la maggior parte del tempo non è necessario testare. Oppure potrei semplicemente testarlo con test di accettazione o test di integrazione.
-
La mia terza implementazione fa trapelare la responsabilità dell'applicazione fuori dall'applicazione. Se è un sì, è perché ho chiamato la funzione di presentatore all'interno del controller. Allora perché costruire un presentatore all'interno del controller, non conta come una perdita?