A framework is a collection of code that is leveraged in the development process by providing ready-made components. Through the use of frameworks, architectural patterns and structures are created, which help speed up the development process. This Zone contains helpful resources for developers to learn about and further explore popular frameworks such as the Spring framework, Drupal, Angular, Eclipse, and more.
Angular Apps Don’t Need Another Chatbot: Building Agentic UI Workflows With TypeScript
A Field Guide to AI Agent Frameworks
In this blog, you will take a closer look at the different exchange types that can be used in RabbitMQ. All are demonstrated by means of examples in a Spring Boot application. Enjoy! Introduction In the previous blog, you learned the basic concepts of RabbitMQ and how to use it in a Spring Boot application. However, you only scratched the surface of it, so now it is time to dig a bit deeper into the different exchange types. If you are not yet familiar with the basic concepts, it is advised to read the previous blog. The official RabbitMQ documentation also provides detailed information that is worth reading. Sources used in this blog can be found on GitHub. Prerequisites Prerequisites for reading this blog are: Basic knowledge of Java;Basic knowledge of Spring Boot;Basic knowledge of Docker Compose;Basic knowledge of RabbitMQ. Topics The code can be found in the topics module. In the previous blog, you created two consumers A and B. Consumer A was bound to Queue A with routing key event.general.*. Consumer B was bound to Queue B with routing keys event.general.* and event.specific.*. The asterisk (*) wildcard was used and is a substitute for exactly one word. In the examples, the routing keys event.general.message and event.specific.message were used. You can also use the hash (#) wildcard, and this is a substitute for zero or more words. This is visualized in the figure below. In the RabbitMqConfig, you declare queue C and bind it to the TopicExchange with routing key event.general.#. Java public static final String QUEUE_CONSUMER_C = "consumer-c.queue"; public static final String ROUTING_KEY_NESTED_GENERAL_MESSAGE = "event.general.#"; @Bean Binding bindingConsumerBSpecific(Queue queueConsumerB, TopicExchange exchange) { return BindingBuilder.bind(queueConsumerB).to(exchange).with(ROUTING_KEY_SPECIFIC_MESSAGE); } @Bean public Queue queueConsumerC() { return new Queue(QUEUE_CONSUMER_C, false); } @Bean Binding bindingConsumerCNestedGeneral(Queue queueConsumerC, TopicExchange exchange) { return BindingBuilder.bind(queueConsumerC).to(exchange).with(ROUTING_KEY_NESTED_GENERAL_MESSAGE); } In the MessageController, you create an endpoint for sending a message with routing key event.general.message.nested. This routing key will not match the bindings of consumers A and B. Java @RequestMapping( method = RequestMethod.POST, value = "send-nested-general" ) public ResponseEntity<Void> sendNestedGeneralMessage(@RequestBody String message) { messageService.sendMessage("event.general.message.nested", message); return new ResponseEntity<>(HttpStatus.CREATED); } The ReceiverC listens to messages received in queue C and prints a message. Java @Component public class ReceiverC { @RabbitListener(queues = RabbitMqConfig.QUEUE_CONSUMER_C) public void receiveMessage(String message) { System.out.println("Queue Consumer C received <" + message + ">"); } } Start the application from within the topics module. Shell mvn spring-boot:run First, post a general message; this should be received by all consumers. Shell curl -X POST http://localhost:8080/send-general \ -H "Content-Type: text/plain" \ -d "This is a general message" In the application console log, you notice that all consumers receive the message. Plain Text Queue Consumer B received <This is a general message> Queue Consumer A received <This is a general message> Queue Consumer C received <This is a general message> Now, post a nested general message, which should be received only by consumer C. Shell curl -X POST http://localhost:8080/send-nested-general \ -H "Content-Type: text/plain" \ -d "This is a nested general message" In the application console log, you notice that the message is only received by consumer C. Plain Text Queue Consumer C received <This is a nested general message> Work Queues The code can be found in the work module. With work queues, you can publish a message and dispatch it to a pool of consumers. One of the consumers will pick up the message and start processing it. This is especially useful for dispatching long-running tasks. You use the default direct exchange in this case, and the queue name is used as the routing key. No need to use a custom exchange. This is visualized in the figure below. The RabbitMqConfig is quite small; you only define the queue. Java @Configuration public class RabbitMqConfig { public static final String QUEUE_TASK = "task.queue"; @Bean public Queue queueTask() { return new Queue(QUEUE_TASK, false); } } When sending a message via an endpoint, you use the queue name as the routing key. Java @RequestMapping( method = RequestMethod.POST, value = "send-work" ) public ResponseEntity<Void> sendWorkMessage(@RequestBody String message) { messageService.sendMessage(RabbitMqConfig.QUEUE_TASK, message); return new ResponseEntity<>(HttpStatus.CREATED); } Every consumer listens to the queue. Java @Component public class ReceiverA { @RabbitListener(queues = RabbitMqConfig.QUEUE_TASK) public void receiveMessage(String message) { System.out.println("Task picked up by Consumer A <" + message + ">"); } } @Component public class ReceiverB { @RabbitListener(queues = RabbitMqConfig.QUEUE_TASK) public void receiveMessage(String message) { System.out.println("Task picked up by Consumer B <" + message + ">"); } } @Component public class ReceiverC { @RabbitListener(queues = RabbitMqConfig.QUEUE_TASK) public void receiveMessage(String message) { System.out.println("Task picked up by Consumer C <" + message + ">"); } } Start the application from within the work module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-work \ -H "Content-Type: text/plain" \ -d "This is a work message" The message is processed by one consumer. Plain Text Task picked up by Consumer A <This is a work message> Fanout The code can be found in the fanout module. With fanout, you want to broadcast messages to all queues. You send messages to the exchange, but there is no need to specify a routing key. You can also ensure that temporary queues are used. When temporary queues are used, the queue name will be generated. In the RabbitMqConfig, you define a FanoutExchange. The queues are defined as an AnonymousQueue. This creates a non-durable, exclusive, auto-delete queue with a generated name. You bind the queues to the exchange. Java @Configuration public class RabbitMqConfig { public static final String FANOUT_EXCHANGE_NAME = "fanout.exchange"; @Bean FanoutExchange fanoutExchange() { return new FanoutExchange(FANOUT_EXCHANGE_NAME); } @Bean public Queue queueConsumerA() { return new AnonymousQueue(); } @Bean Binding bindingConsumerA(Queue queueConsumerA, FanoutExchange exchange) { return BindingBuilder.bind(queueConsumerA).to(exchange); } @Bean public Queue queueConsumerB() { return new AnonymousQueue(); } @Bean Binding bindingConsumerBGeneral(Queue queueConsumerB, FanoutExchange exchange) { return BindingBuilder.bind(queueConsumerB).to(exchange); } @Bean Binding bindingConsumerBSpecific(Queue queueConsumerB, FanoutExchange exchange) { return BindingBuilder.bind(queueConsumerB).to(exchange); } } In order to send messages, you only need to send them to the exchange. This can be seen in the MessageService. Java public void sendMessage(String message) { rabbitTemplate.convertAndSend(RabbitMqConfig.FANOUT_EXCHANGE_NAME, "", message); } On the receiving side, you listen to the generated queue name (thus not a specific one in this case). Java @Component public class ReceiverA { @RabbitListener(queues = "#{queueConsumerA.name}") public void receiveMessage(String message) { System.out.println("Queue Consumer A received <" + message + ">"); } } @Component public class ReceiverB { @RabbitListener(queues = "#{queueConsumerB.name}") public void receiveMessage(String message) { System.out.println("Queue Consumer B received <" + message + ">"); } } Start the application from within the fanout module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-to-all \ -H "Content-Type: text/plain" \ -d "This is a fanout message" In the application console log, you notice that the message is consumed by all queues. Plain Text Queue Consumer B received <This is a fanout message> Queue Consumer A received <This is a fanout message> RPC The code can be found in the RPC module. Remote Procedure Call (RPC) can be used when you need to execute a function on a remote application and wait for the result. The event is sent to the queue and is processed by Consumer A. The result is sent to a queue in the replyTo field of the request. The publisher waits for data to be returned on this callback queue. When the message appears, it checks the correlationId. If it matches the value of the request, the response is returned to the publisher. All of this is done automatically by the RabbitTemplate. In the RabbitMqConfig, a DirectExchange is used. With a DirectExchange, you match exactly on events; you cannot use wildcards here, just like a TopicExchange. Java @Configuration public class RabbitMqConfig { public static final String QUEUE_CONSUMER_A = "consumer-a.queue"; public static final String DIRECT_EXCHANGE_NAME = "events.exchange"; public static final String ROUTING_KEY_RPC_MESSAGE = "event.rpc"; @Bean DirectExchange eventsExchange() { return new DirectExchange(DIRECT_EXCHANGE_NAME); } @Bean public Queue queueConsumerA() { return new Queue(QUEUE_CONSUMER_A, false); } @Bean Binding bindingConsumerA(Queue queueConsumerA, DirectExchange exchange) { return BindingBuilder.bind(queueConsumerA).to(exchange).with(ROUTING_KEY_RPC_MESSAGE); } } The MessageController contains an endpoint for sending the event. Java @RequestMapping( method = RequestMethod.POST, value = "send-rpc" ) public ResponseEntity<Void> sendRpcMessage(@RequestBody String message) { messageService.sendMessage(message); return new ResponseEntity<>(HttpStatus.CREATED); } In the MessageService, you use convertSendAndReceive and process the response. Java public void sendMessage(String message) { Object response = rabbitTemplate.convertSendAndReceive(RabbitMqConfig.DIRECT_EXCHANGE_NAME, ROUTING_KEY_RPC_MESSAGE, message); if (response != null) { System.out.println("Sender received response: " + response); } else { System.out.println("No response received"); } } In the receiver, you receive the message and send a response. Do note that some additional processing is added in order to trigger a timeout. More on that in a moment. Java @Component public class ReceiverA { @RabbitListener(queues = RabbitMqConfig.QUEUE_CONSUMER_A) public String receiveMessage(String message) { System.out.println("Queue Consumer A received <" + message + ">"); if (message.equals("This is an rpc message")) { return "success"; } else if (message.equals("This is a timeout message")) { try { Thread.sleep(10000); } catch (InterruptedException e) { throw new RuntimeException(e); } return "success"; } else { return "failure"; } } } Start the application from within the rpc module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-rpc \ -H "Content-Type: text/plain" \ -d "This is an rpc message" In the application console log, you notice that the message is consumed by consumer A, and that a successful response is received by the publisher. Plain Text Queue Consumer A received <This is an rpc message> Sender received response: success But what if it takes too long to process the message? In real life, the remote application can be unreachable for one reason or another. Send a timeout message. Shell curl -X POST http://localhost:8080/send-rpc \ -H "Content-Type: text/plain" \ -d "This is a timeout message" In the MessageService, the response will return null, and a timeout exception is raised. Plain Text Queue Consumer A received <This is a timeout message> No response received 2026-04-25T14:50:16.785+02:00 WARN 482297 --- [MySpringRabbitMqPlanet] [pool-2-thread-8] o.s.amqp.rabbit.core.RabbitTemplate : Reply received after timeout for 2 2026-04-25T14:50:16.785+02:00 WARN 482297 --- [MySpringRabbitMqPlanet] [pool-2-thread-8] s.a.r.l.ConditionalRejectingErrorHandler : Execution of Rabbit message listener failed. org.springframework.amqp.rabbit.support.ListenerExecutionFailedException: Listener threw exception at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.wrapToListenerExecutionFailedExceptionIfNeeded(AbstractMessageListenerContainer.java:1795) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1687) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1612) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1599) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doExecuteListener(AbstractMessageListenerContainer.java:1590) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListenerAndHandleException(AbstractMessageListenerContainer.java:1539) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1520) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.callExecuteListener(DirectMessageListenerContainer.java:1206) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.handleDelivery(DirectMessageListenerContainer.java:1163) ~[spring-rabbit-4.0.2.jar:4.0.2] at com.rabbitmq.client.impl.ConsumerDispatcher$5.run(ConsumerDispatcher.java:149) ~[amqp-client-5.27.1.jar:5.27.1] at com.rabbitmq.client.impl.ConsumerWorkService$WorkPoolRunnable.run(ConsumerWorkService.java:111) ~[amqp-client-5.27.1.jar:5.27.1] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) ~[na:na] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) ~[na:na] at java.base/java.lang.Thread.run(Thread.java:1474) ~[na:na] Caused by: org.springframework.amqp.AmqpRejectAndDontRequeueException: Reply received after timeout at org.springframework.amqp.rabbit.core.RabbitTemplate.onMessage(RabbitTemplate.java:2721) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectReplyToMessageListenerContainer.lambda$setMessageListener$0(DirectReplyToMessageListenerContainer.java:93) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1683) ~[spring-rabbit-4.0.2.jar:4.0.2] ... 12 common frames omitted 2026-04-25T14:50:16.790+02:00 ERROR 482297 --- [MySpringRabbitMqPlanet] [pool-2-thread-8] .l.DirectReplyToMessageListenerContainer : Failed to invoke listener org.springframework.amqp.rabbit.support.ListenerExecutionFailedException: Listener threw exception at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.wrapToListenerExecutionFailedExceptionIfNeeded(AbstractMessageListenerContainer.java:1795) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1687) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1612) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1599) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doExecuteListener(AbstractMessageListenerContainer.java:1590) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListenerAndHandleException(AbstractMessageListenerContainer.java:1539) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1520) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.callExecuteListener(DirectMessageListenerContainer.java:1206) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.handleDelivery(DirectMessageListenerContainer.java:1163) ~[spring-rabbit-4.0.2.jar:4.0.2] at com.rabbitmq.client.impl.ConsumerDispatcher$5.run(ConsumerDispatcher.java:149) ~[amqp-client-5.27.1.jar:5.27.1] at com.rabbitmq.client.impl.ConsumerWorkService$WorkPoolRunnable.run(ConsumerWorkService.java:111) ~[amqp-client-5.27.1.jar:5.27.1] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) ~[na:na] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) ~[na:na] at java.base/java.lang.Thread.run(Thread.java:1474) ~[na:na] Caused by: org.springframework.amqp.AmqpRejectAndDontRequeueException: Reply received after timeout at org.springframework.amqp.rabbit.core.RabbitTemplate.onMessage(RabbitTemplate.java:2721) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectReplyToMessageListenerContainer.lambda$setMessageListener$0(DirectReplyToMessageListenerContainer.java:93) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1683) ~[spring-rabbit-4.0.2.jar:4.0.2] ... 12 common frames omitted How to solve this? In this case, you are better off using the AsyncRabbitTemplate. This template is not automatically autowired, so you have to define it as a bean. Let's do so in the RabbitMqConfig. Java @Bean public AsyncRabbitTemplate asyncRabbitTemplate(RabbitTemplate rabbitTemplate) { return new AsyncRabbitTemplate(rabbitTemplate); } In the MessageController, you define an endpoint to trigger the async template. Java @RequestMapping( method = RequestMethod.POST, value = "send-async" ) public ResponseEntity<Void> sendAsyncMessage(@RequestBody String message) { messageService.sendAsyncMessage(message); return new ResponseEntity<>(HttpStatus.CREATED); } In the MessageService, you autowire the AsyncRabbitTemplate. And because it is an async call, you catch the response by means of a CompletableFuture. Java public void sendAsyncMessage(String message) { CompletableFuture<Object> future = asyncRabbitTemplate.convertSendAndReceive(RabbitMqConfig.DIRECT_EXCHANGE_NAME, ROUTING_KEY_RPC_MESSAGE, message); future.thenAccept(response -> { if (response != null) { System.out.println("Sender received response: " + response); } else { System.out.println("No response received"); } }); } Start the application from within the rpc module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-async \ -H "Content-Type: text/plain" \ -d "This is a timeout message" In the application log, you see the same result: the response is null, but no timeout exception anymore. Conclusion In this post, you learned different exchange types. Each serves its own use case. It is up to you to choose the right pattern for your use case.
Most Docker content targets web developers shipping stateless services. However, data engineers, who represent a huge and growing population of Dockers users, are mostly left to figure things out alone, and it shows. The get pipelines that pass locally, but explode on clusters. They pit notebook-only development against expensive cloud workspaces, and more. This article applies six years of production data platform experience in financial services and healthcare to a question nobody answers well: How to you make a laptop behave like a lakehouse? A Familiar Routine If you build data pipelines for a living, you've lived this story. Your PySpark job runs perfectly in a cloud notebook. You productionize it, push it through CI, deploy it to the cluster, and it fails. A dependency mismatch. A different Spark minor version. A Delta Lake protocol feature your local wheel doesn't know about. A timezone default nobody set. Web developers solved "works on my machine" a decade ago with containers. Data engineers, somehow, are still developing against shared cloud workspaces, paying per-minute cluster costs to debug a GROUP BY, and discovering environment drift in production. This article is the workflow I wish someone had handed me years ago: a fully containerized lakehouse development environment — Spark, Delta Lake, object storage, a catalog, and orchestration — that runs on a laptop, mirrors production closely enough to trust, and plugs into CI without mocks. The Real Problem: Data Pipelines Have Four Environments, Not One A typical stateless web service has one environment to reproduce: the app runtime. A data pipeline has at least four, and they drift independently: The compute runtime — Spark version, Scala version, JVM, Python, native libs (Arrow, Parquet, libhdfs).The table format layer — Delta Lake / Iceberg versions and protocol versions, which are not the same thing.The storage layer — S3/ADLS semantics: multipart uploads, eventual consistency quirks, path-style vs virtual-hosted access.The orchestration layer — the scheduler's Python environment, which is famously not your job's environment. Mocking any one of these in tests means you aren't testing the thing that breaks. The goal of containerizing a lakehouse is to pin all four layers in code and version them together. Step 1: A Reproducible Spark Image You Actually Control Don't develop against latest. Build a base image that pins every layer of the compute runtime and treat it like an artifact: Dockerfile # syntax=docker/dockerfile:1.7 FROM eclipse-temurin:17-jre-jammy AS base ARG SPARK_VERSION=3.5.4 ARG DELTA_VERSION=3.3.0 ARG HADOOP_AWS_VERSION=3.3.6 RUN apt-get update && apt-get install -y --no-install-recommends \ python3.11 python3-pip tini && \ rm -rf /var/lib/apt/lists/* # Pin Spark itself, not just PySpark RUN curl -fsSL https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SPARK_VERSION}-bin-hadoop3.tgz \ | tar -xz -C /opt && mv /opt/spark-${SPARK_VERSION}-bin-hadoop3 /opt/spark ENV SPARK_HOME=/opt/spark PATH=$PATH:/opt/spark/bin PYTHONHASHSEED=0 TZ=UTC # Delta + S3 connectors resolved at build time, never at job submit time RUN /opt/spark/bin/spark-shell --packages \ io.delta:delta-spark_2.12:${DELTA_VERSION},org.apache.hadoop:hadoop-aws:${HADOOP_AWS_VERSION} \ -e "println(\"deps cached\")" && \ cp /root/.ivy2/jars/*.jar /opt/spark/jars/ COPY requirements.lock /tmp/ RUN pip install --no-cache-dir -r /tmp/requirements.lock # Never run Spark as root RUN useradd -m -u 1001 spark USER 1001 ENTRYPOINT ["/usr/bin/tini", "--"] Three details that matter more than they look: --packages at build time, not submit time. Resolving connector JARs at spark-submit is the #1 source of "it worked yesterday" failures — Maven Central is a runtime dependency you didn't mean to have.PYTHONHASHSEED=0 and TZ=UTC kill two classes of "non-deterministic only in prod" bugs.A lockfile, not requirements.txt. Compile with pip-compile or uv pip compile so transitive dependencies (looking at you, pandas/pyarrow) can't drift. Step 2: The Lakehouse-In-A-Box With Docker Compose Here's the part most teams never build: the rest of the lakehouse, locally. MinIO stands in for S3 (it speaks the same API), and a real Spark master/worker pair stands in for the cluster, because local[*] mode hides every serialization and shuffle bug you'll meet in production. Dockerfile # compose.yaml services: spark-master: build: . command: /opt/spark/sbin/start-master.sh environment: [SPARK_NO_DAEMONIZE=true] ports: ["7077:7077", "8080:8080"] spark-worker: build: . command: /opt/spark/sbin/start-worker.sh spark://spark-master:7077 environment: - SPARK_NO_DAEMONIZE=true - SPARK_WORKER_MEMORY=4g - SPARK_WORKER_CORES=2 depends_on: [spark-master] deploy: replicas: 2 # >1 worker = real shuffles, real serialization minio: image: minio/minio:RELEASE.2025-09-07T16-13-09Z command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: localdev MINIO_ROOT_PASSWORD: localdev-secret ports: ["9000:9000", "9001:9001"] volumes: [lake-data:/data] healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 5s mc-init: # create the bronze/silver/gold buckets on boot image: minio/mc:latest depends_on: { minio: { condition: service_healthy } } entrypoint: > /bin/sh -c "mc alias set local http://minio:9000 localdev localdev-secret && mc mb -p local/lakehouse/bronze local/lakehouse/silver local/lakehouse/gold" volumes: lake-data: Point Spark at MinIO with three config lines and your medallion pipeline reads and writes s3a://lakehouse/... paths exactly like production: Python spark = (SparkSession.builder .config("spark.hadoop.fs.s3a.endpoint", "http://minio:9000") .config("spark.hadoop.fs.s3a.path.style.access", "true") .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") .config("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") .getOrCreate()) docker compose up and you have bronze → silver → gold on your laptop. Total cloud cost of a debugging session: $0. Step 3: Integration Tests That Run Real Spark — Testcontainers The payoff of all this is CI you can trust. With Testcontainers, your pipeline tests spin up the same images your developers use: Python import pytest from testcontainers.minio import MinioContainer from pyspark.sql import SparkSession @pytest.fixture(scope="session") def lake(request): with MinioContainer("minio/minio:RELEASE.2025-09-07T16-13-09Z") as minio: yield minio def test_silver_dedup_keeps_latest_record(lake, spark): # write duplicate customer events to bronze bronze_path = f"s3a://test/bronze/customers" write_fixture_events(spark, bronze_path, duplicates=True) run_silver_dedup(spark, bronze_path, "s3a://test/silver/customers") result = spark.read.format("delta").load("s3a://test/silver/customers") assert result.count() == EXPECTED_UNIQUE assert latest_record_wins(result) No mocked DataFrames. No unittest.mock.patch("boto3..."). The test exercises Delta's actual transaction log against actual object storage. When this suite is green, deployments stop being scary. A pattern I use in regulated environments: keep a fixtures/ directory of small, synthetic Parquet files that mirror production schemas (never production data), and version them with the code. Schema drift then fails a unit test instead of a 2 a.m. pipeline run. Step 4: One Image From Laptop → CI → Production The final principle: the image you test is the artifact you ship. Multi-stage builds let one Dockerfile serve dev (with Jupyter, debuggers) and prod (minimal, non-root): Dockerfile FROM base AS dev USER root RUN pip install --no-cache-dir jupyterlab pytest debugpy USER 1001 FROM base AS prod COPY --chown=1001:1001 src/ /app/src/ COPY --chown=1001:1001 jobs/ /app/jobs/ # nothing else — no notebooks, no test deps, no shell tools you don't need In CI: build once, tag with the git SHA, run the Testcontainers suite against prod, scan it (Docker Scout, or your registry's scanner), sign it, and promote that exact digest through staging to the scheduler. Whether the scheduler is Airflow's DockerOperator/KubernetesPodExecutor or a managed Spark platform pulling custom containers, the principle holds: environments are immutable, versioned, and identical by construction. Lessons Learned From Production Run ≥2 workers locally.local[*] mode never serializes between JVMs. The day you switch to a real cluster, every closure-capture and UDF-pickling bug appears at once. Two 2-core workers in Compose surfaces them on day one.Pin the table format protocol, not just the library. Delta and Iceberg both evolve table protocol versions. A newer writer can produce tables an older reader can't open. Encode the protocol version in your image build args and test reads with the oldest reader you support.MinIO is a stand-in, not a clone. It won't reproduce S3 request throttling or cross-region latency. Keep a small smoke-test suite that runs against real object storage nightly; do everything else locally.Resource-limit your local Spark. Without SPARK_WORKER_MEMORY caps, a skewed join will cheerfully eat your laptop. Limits also force you to think about partitioning early — which is the point.Treat the orchestrator's image as layer four. Airflow DAG-parse environments drift too. Containerize the scheduler with the same lockfile discipline as the jobs. Production Considerations Before you take this pattern to a real platform team, three things to plan for: secrets (local Compose uses throwaway creds; production should inject via your cloud's secret manager or Docker secrets — never baked into images), image provenance (sign images and generate SBOMs in CI; regulated industries will ask, and in 2026 the tooling is mature enough that "we didn't get to it" no longer flies), and base image hygiene (start from minimal, hardened bases and rebuild on a schedule, not just on code change — CVEs don't wait for your sprint). Conclusion Containers gave application developers reproducibility ten years ago. Data engineering is finally having the same moment — and the teams that containerize their lakehouse development loop ship faster, test honestly, and stop paying cloud bills to find typos. Try it: clone the Compose stack above, point your gnarliest pipeline at it, and see what breaks locally that used to break in prod. Then tell me about it — I'd genuinely like to hear which layer drifted on you. If this was useful, follow me here and on LinkedIn. Next up in this series: load-testing Delta merge performance locally, and contract testing between pipeline stages.
Ever since Swift Concurrency was introduced, its main mission has been clear: keep memory safe without making us write callback hell. But if we’re being honest, context switching-specifically thread hopping-has always been a bit of a head-scratcher. How many times have you marked an async function as nonisolated on a @MainActor class, only to watch it instantly jump off to the cooperative global pool for no obvious reason? Swift 6.2 addresses this head-on with Approachable Concurrency and its underlying flag, NonisolatedNonsendingByDefault. Let’s break down what actually changes under the hood, how @concurrent fits into the picture, and what this all looks like when stepping through real code. What Changes With NonisolatedNonsendingByDefault Before Swift 6.2 (or with Approachable Concurrency turned off), any nonisolated async function would immediately yield its execution to Swift’s global cooperative executor whenever you called await. That meant constant, often unnecessary thread switching. With Approachable Concurrency enabled (APPROACHABLE_CONCURRENCY = YES), that default behavior flips. Ordinary async methods now behave much like their synchronous counterparts. They stay on the caller’s executor by default instead of hopping away. A few quick rules to keep in mind: nonsending: The function isn’t bound to a specific actor’s isolation domain, but it keeps the execution context of whoever called it.@concurrent: The explicit opt-in attribute telling the compiler, “No, seriously, run this on the global concurrent pool.”Good to know: @concurrent automatically implies nonisolated, so writing both is redundant. Comparing the Flags: A Basic Test Let’s look at a straightforward example to see the difference in practice: Swift @MainActor class ViewModel { var title = "Hello" func updateData() { print("1:", Thread.isMain) } nonisolated func helperMethod() async { print("2:", Thread.isMain) } @concurrent func thirdMethod() async { print("3:", Thread.isMain) } } // Calling it from a MainActor context: Task { let viewModel = ViewModel() viewModel.updateData() await viewModel.helperMethod() await viewModel.thirdMethod() } Quick Compiler Tip: When testing thread execution across different isolation contexts, you might run into compiler warnings or errors when accessing Thread.isMainThread. To cleanly check the main thread without triggering actor isolation warnings, use a nonisolated helper extension: Swift extension Thread { static nonisolated var isMain: Bool { Thread.isMainThread } } Here’s what gets printed depending on your project settings: OutputAPPROACHABLE_CONCURRENCY = NOAPPROACHABLE_CONCURRENCY = YES1: updateData()truetrue2: helperMethod()false (Background)true (Main Thread)3: thirdMethod()false (Background)false (Background) What’s happening here? When set to NO: Calling helperMethod() drops off the main actor and executes on a background thread (false).When set to YES: helperMethod() isn’t isolated, but thanks to nonsending, it inherits the caller’s context. Since the calling Task runs on @MainActor, helperMethod() stays right there on the main thread.thirdMethod() is marked @concurrent, so it always hops to a background worker thread regardless of the build setting. Deep Dive: Following the Execution Chain To really see how thread hopping behaves during nested calls and returns, let’s trace a slightly more complex scenario involving a custom global actor: Swift @globalActor actor BackgroundActor { static let shared = BackgroundActor() } @MainActor class ViewModel { var name = "Swift 6" // 1. Synchronous isolated method func runTest() { print("1:", Thread.isMain) Task { await complexHelper() } } // 2. Async nonisolated helper nonisolated func complexHelper() async { print("2:", Thread.isMain) // Jumping over to our custom actor await BackgroundActor.shared.doWork { print("3:", Thread.isMain) } print("4:", Thread.isMain) // Calling a sync nonisolated helper syncHelper() } // 3. Synchronous nonisolated helper nonisolated func syncHelper() { print("5:", Thread.isMain) } } extension BackgroundActor { func doWork(_ operation: @Sendable () -> Void) async { operation() Task { print("6:", Thread.isMain) } } } Side-by-Side Execution Trace: StepAPPROACHABLE_CONCURRENCY = NOAPPROACHABLE_CONCURRENCY = YES1truetrue2falsetrue ← Stays on caller’s thread3falsefalse ← Hopped to BackgroundActor4falsetrue ← Returned to caller context5falsetrue ← Synchronous call from step 46falsefalse ← Task spawned inside BackgroundActor Why steps 2, 4, and 5 change in Swift 6.2: Step 2 (complexHelper): Because the caller is on @MainActor, complexHelperstarts executing on the main thread (true).Step 3 (doWork): We explicitly await a method on BackgroundActor, so execution correctly hops over to a background thread (false).Step 4 (After await doWork): Here’s the key difference. When doWorkfinishes, control resumes in complexHelper. Under Swift 6.2, the method remembers where it was called from, so it hops back to the Main Thread (true).Step 5 (syncHelper): This is a plain synchronous call made right after step 4, so it stays on the main thread (true). Wrapping Up Swift 6.2’s Approachable Concurrency makes writing async Swift feel a lot more natural: Fewer random context switches: Your app spends less time hopping back and forth across threads when it doesn’t need to.Predictable execution: Async code holds onto its caller’s context until you explicitly use @concurrent or call into a different actor.Easier mental model: Async methods now align much closer with how we expect synchronous code to flow, removing a big chunk of the concurrency learning curve.
A live production integration case study. Introduction and Purpose of This Article This article is written for mid- and high-level managerial and technical decision-makers. I am the author of the open-source Java library MgntUtils. The article presents an analysis of a real integration of the stack trace-filtering feature from that library into a live commercial production environment. A few important clarifications up front: This is not a side-project pilot and not a lab demo. The feature was integrated into a production service of a company that serves a high volume of real customers. Due to legal constraints, I am not at liberty to name the company.This is not a how-to article for implementers. If you came looking for code samples or logging-framework wiring, please see the dedicated articles listed in the Disclaimer below.MgntUtils can be used in Java projects and in other JVM-based languages such as Kotlin. Before diving into the production numbers, it is worth stating briefly what the feature does and why those numbers matter. Server-side stack traces are usually full of framework and infrastructure noise — proxies, filter chains, containers, thread pools, and similar boilerplate — while the few lines that actually explain the failure are easy to lose in the pile. The MgntUtils filtering utility keeps the application frames and the exception / Caused by chain, and collapses that noise. The result is a much shorter stack trace without losing the information you actually need. When those stack traces are later consumed — sent to an LLM for analysis, or opened by an engineer — that reduction can mean: Substantial AI token savingsTypically more accurate AI root-cause answers, because the model has less framework noise to latch onto and hallucinate aboutA meaningful productivity boost for human triage The rest of this article focuses on what was observed after integrating this feature in production: the measured benefits, how to interpret them, and the integration experience itself — including gotchas that only surfaced in a real live environment, as opposed to a pilot project. Disclaimer This article deliberately does not discuss the technical design of stack trace filtering or the technical details of the integration. Each of those topics has its own dedicated article: Filtering Java Stack Traces With MgntUtils Library DZone: https://dzone.com/articles/filter-java-stacktrace-mgntutilsDEV Community: https://dev.to/mgantman/java-stacktrace-filtering-utility-1c1i Zero-Code-Change Stack Trace Filtering for Spring Boot: An Infrastructure-Level Integration DEV Community: https://dev.to/mgantman/zero-code-change-stacktrace-filtering-for-spring-boot-an-infrastructure-level-integration-3fk5 Production Results and Benefits Below are the observations and conclusions from monitoring the live production system after the feature integration. The feature had been running for about a month, and filtering was also temporarily turned off for comparison. What the Production Environment Looked Like Anonymized sketch of the deployment (enough to judge fit, without identifying the company): High-traffic JVM/Spring Boot service in a commercial production estateStructured JSON logging to a major observability platformObservability billing dominated by per-event (not per-byte) pricingIn a typical production day, that service emitted on the order of ~70,000+ log events carrying a stack trace That is a large stream of stack trace payloads — expensive if fed to an LLM, and tiring if engineers open them by hand. Stack Trace Volume Reduction Range in Production Filtering was measured across production stack traces with filtering on vs off. Observed size/token reductions typically fell in roughly the ~75%–95% range: Toward the high end (~90–95%): framework-heavy request-handling traces (long security/container/proxy tails)Toward the lower end (~75%+): more application-dense traces, where a larger share of frames is your own code The average reduction on a typical trace in this environment was about ~91%. The table below is a real before/after example — shown so you can see what that looks like in practice: MetricUnfilteredFilteredReductionLines19518~91%Bytes~22,200~1,900~91%Input tokens (approx.)~6,300~540~91%Application framesall (buried in noise)all (kept)no signal lost Every application frame in the business call path was retained; what disappeared was framework and infrastructure noise (proxies, filter chains, container/thread-pool frames, and similar boilerplate). Stack traces tokenize poorly for LLMs — package separators, generated class names, and (File:line) markers all split into extra tokens — so the token reduction tracks the size reduction closely. Root-cause readability was unchanged. In both versions, the failure was identifiable from the application frames and the exception message. Filtering did not remove diagnostic signal; it removed the large majority of the payload that never helped. What Improved AI analysis: cheaper and more accurate (when exceptions are analyzed). For every exception sent to an LLM, the stack trace input payload shrank by roughly ~75–95% depending on the trace shape (~5,800 tokens saved on a typical ~91% trace). That saving repeats for every analyzed event. In an environment where tens of thousands of stack traces are emitted per day, any AI triage, clustering, or “explain this error” pipeline pays that tax over and over unless the noise is stripped first. Cost is only half of the AI benefit. Filtering also improves answer quality. The removed frames are framework and infrastructure boilerplate — identical across many errors and unrelated to the application failure. When those frames remain in the prompt, models often latch onto them and hallucinate a root cause in the noise. With them collapsed, the model is steered toward the application frames and exception message that actually explain the failure — so analysis is not only cheaper, but typically more accurate. Sensitivity calculator (illustrative — not this company’s AI spend). If your org analyzes exceptions with an LLM, you can size token cost roughly as: Plain Text annual token saving ≈ (exceptions analyzed per year) × (tokens saved per exception) × (model input price per token) Using ~5,800 tokens saved per exception (average on a typical ~91% trace) and an illustrative model input price of $3 per 1 million input tokens: Analyzed exceptions / dayApprox. tokens saved / dayApprox. saving / year5,000~29M~$32K50,000~290M~$318K250,000~1.45B~$1.6M Plug in your own analysis volume, your place in the ~75–95% reduction range, and your model pricing. The production measurement that is firm is the observed per-exception reduction range, with application frames preserved. Secondary AI upside: More errors per context window. Because a typical filtered stack trace is so much smaller (~540 tokens vs ~6,300 in the example above), many more distinct exceptions fit into a single model call. That is a capability change, not just a cost saving: cross-error analysis — clustering failures, or asking “what went wrong in the last N hours?” — becomes practical instead of blowing the context window on framework noise. It is secondary to the per-exception token and accuracy benefits, but it matters for any AI workflow that looks at more than one error at a time. Human triage productivity. Engineers reading a filtered typical trace see the full application call path at the top (~18 lines in the example above) instead of scrolling through ~195 lines to confirm there is no hidden nested cause and to piece the business path together. For on-call and incident review, that is a direct readability win. What Changed in Log Volume — and What Did Not It helps to separate event count from bytes per event. Event count did not change. A stack trace is still one log event whether it is 195 lines or 18. If your observability vendor bills per event (or per indexed log line item), filtering does not reduce that charge. In this production environment, that was the dominant billing model — so there were no savings on a per-event bill. Bytes per stack trace event did change. Each filtered stack trace was roughly ~75–95% smaller than its unfiltered counterpart (commonly ~90% for framework-heavy traces). There is a real reduction in stack trace payload size. How much that shows up in total log volume is not deterministic. Overall space / ingested-byte savings depend on what share of all logs are stack traces: Plain Text overall byte reduction ≈ (stacktrace share of total log volume) × (~75–95% reduction on those stacktraces) In this company’s environment, stack traces were only about ~1% of total log volume — which is unusually low (an anomaly for many systems, but what we observed here). Cutting ~90% of that 1% yields only a fraction of a percent of total logs, which is easy to lose inside normal day-to-day traffic variance. That is why aggregate ingested-byte charts did not show a clear step when filtering was toggled. In another organization where stack traces are a much larger share of log volume, the same per-trace cut would produce a more visible space saving. Those savings are real in principle, but variable by workload and not the main point of this case study. The main point here is consumption cost. The firm, repeatable benefit we are highlighting is what happens when a stack trace is analyzed by an LLM or read by an engineer: large payload reduction, same diagnostic signal. Treat log-space savings as a possible secondary effect, sized by your own stack trace-to-total-logs ratio — not as the success criterion for this feature. How to Read These Results as a Decision Maker QuestionAnswer from this production caseDid filtering remove useful diagnostic information?No — application frames and exception chain structure remained.How large is the per-exception reduction?Roughly ~75–95% across production traces (often ~90%+ on framework-heavy request traces).Does that reduce per-event log billing?No — event count is unchanged.Is there space / byte saving?Yes per stack trace (~75–95%); overall only if stack traces are a meaningful share of total logs (here ~1%, so barely visible).Where is the upside for AI analysis?Far fewer tokens and less hallucination on framework noise — cheaper and typically more accurate.AI context-window upside?More exceptions fit in a single context window — useful for clustering or “what failed in the last N hours?” analysis.Other upside?Time saved when humans read errors.Who should adopt it?Teams that already (or soon will) send production exceptions to LLMs at volume, and/or teams whose engineers routinely open noisy stack traces. The production evidence supports a clear, bounded claim: when stack traces are consumed, filtering delivers a large, repeatable reduction in payload size with no loss of application signal. Per-event log bills do not drop. Overall log-space savings may exist but depend on stack traces’ share of total volume — and are not the primary reason to adopt the feature. Integration Experience I started from an implementation I already had in the MgntUtilsUsage side-project repository — a runnable Spring Boot demo of MgntUtils features, meant to emulate real-life apps as closely as possible. It was a very good starting point. Still, as I worked through the live commercial integration, a few gotchas surfaced that a single-JVM demo simply does not force you to confront. Gotchas That Showed Up in a Real Production Environment 1. Feature Toggle Storage Across Multiple Containers My demo app runs in a single JVM. A real production service typically runs on several containers that scale in and out. In the demo, the on/off flag for stack trace filtering lived in memory — which is fine for one process, and useless once you have more than one. In a multi-container environment, you need an external, shared flag holder that every instance can read. Redis (or an equivalent shared store available to all containers) is a good candidate. 2. JSON Logging Adapters, Not Only the Classic Logback Pattern When I first modified the Logback configuration, my demo mainly used conventional Logback pattern-based adapters. A real production app will most likely also use a JSON encoder for external logging systems such as Datadog (and similar platforms). That special adapter has its own throwable-handling path, so wiring the filter there is a must — otherwise you can end up with filtered console output locally and unfiltered stack traces in the system that actually matters. 3. Hardening the Fail-Safe Path A fall-back option already existed for the case where anything goes wrong inside the filtering path. For production, that fail-safe had to be hardened a bit further to make it as bullet-proof as possible: if filtering ever fails, the system must still emit a full standard stack trace and must never drop the log event. 4. Logback Is Not the Only Popular Logging Framework This company uses Logback, so that is what the production integration targeted. But Logback is not the only widely used option — my own favorite, for example, is Log4J. For the dedicated integration article (linked in the Disclaimer), I also had to provide Log4J instructions, even though Log4J was not used in this particular environment. Anyone planning an org-wide rollout should assume more than one logging stack may need to be covered. Effort, Timeline, and Outcome All in all, the integration was smooth, and the side-project was close enough to the final result in the real app. About 4–5 hours to get an integrated version up and running in the staging environmentAbout one day of observing staging to make sure there were no unexpected behaviorsThen deployment to production, with about another day of close monitoring before declaring the feature live So roughly half a day of integration work, and about 1.5 working days of testing / staging observation / production monitoring. Not a single bug was found. There are two contributing factors for that: The stack trace-filtering feature itself is mature and battle-tested — I am tempted to say it has no bugs, but let’s just say it is highly stable and reliable.The integration itself is simple enough. The next integration should be even faster, since this one is now well documented (including the dedicated Spring Boot integration article linked in the Disclaimer). If you are interested in integrating this feature into your project, the detailed integration instructions are in the article Zero-Code-Change Stack Trace Filtering for Spring Boot: An Infrastructure-Level Integration. If you are interested in support for the integration, feel free to contact me at or through my LinkedIn profile. Conclusion This case study supports a simple decision: Adopt stack trace filtering if your organization already analyzes production exceptions with LLMs at a meaningful volume, or if engineers routinely open noisy stack traces during triage and on-call. In those cases, the live evidence is clear: typically about ~75–95% less stack trace payload (around ~91% on a typical trace), with application frames preserved — cheaper AI analysis, typically more accurate answers, and easier human reading. Do not adopt it expecting your per-event observability bill to drop, or expecting a large automatic cut in total log volume. Event count does not change. Overall byte savings depend on how large a share stack traces are of all logs — and that varies by organization. Consumption cost is the main point; log-space savings are secondary and workload-dependent. On effort and risk: in this live commercial integration, getting to staging took about half a day of work, followed by roughly a day and a half of staging observation and production monitoring. No bugs were found. The feature is mature, the integration is simple, and the demo-to-production gaps (shared toggle, JSON logging adapters, fail-safe hardening, and covering more than one logging framework) are now documented. If that profile matches your environment — high exception volume that is actually consumed by AI or by people — this is one of the cheaper, lower-risk improvements available. If exceptions are mostly logged and rarely looked at, the benefit will be thin, and that is an honest reason to pass.
A chat screen looks like a weekend project: a list of bubbles and a text input pinned to the bottom. In React Native, it is one of the hardest things to ship well, because it sits on top of the two most hostile surfaces in mobile development: the software keyboard and a scrolling list that changes size while you're looking at it. We're putting LLMs into everything now, and there is still no good drop-in chat view for React Native. You glue together an aging library with strong opinions, or you hand-roll it. I hand-rolled it. Then I made the LLM stream its replies token by token, and the whole thing fell apart in a way that took a week to understand. This is the story of that break, and the fix, which arrived with suspicious good timing as a library release three months ago. The App I work on an app built around an LLM chat: characters that remember you and reply as an open-ended story unfolds. The messages between the reader and the characters are rendered in a chat-like view: an inverted list, the newest message at the bottom, and a composer pinned above the keyboard. Standard chat anatomy. The twist that makes it hard: the character replies are generated by an LLM, and they stream. Tokens arrive in bursts, a few every hundred milliseconds, with a full reply landing over two or three seconds. Each batch makes the last bubble taller. The list isn't just appending a finished message. It's growing on every frame, while the user might be typing, scrolling, or dismissing the keyboard. That single fact is what turns "I'll just use a FlatList" into weeks of work. Why There's Nothing Good to Reach For The first thing I did was look for a library. The honest state of the art: react-native-gifted-chat is the default answer and it's showing its age. It's opinionated about your data shape, its rendering, and its layout, and fighting those opinions costs more than writing your own.Most "chat UI" packages are really just a styled `FlatList` plus a text input. They solve the easy half and hand you the two genuinely hard problems: keyboard choreography and a live-resizing list.The keyboard utilities that _do_ exist (`KeyboardAvoidingView` and friends) were built for forms, not for an inverted list whose last row is growing while the keyboard animates. So I wrote my own keyboard-and-scroll layer. It was close to 500 lines of KeyboardAvoidingView overrides, manual scrollToOffset calls, listeners on keyboard show/hide events, and offset math to keep the composer glued to the keyboard. It worked, demos looked clean, and I shipped it. The Break: Streaming Meets the Keyboard The bug reports were all variations on "the chat is jumpy." No crashes, just jank. I couldn't reproduce it at first because each of the two features behaved perfectly on its own. The keyboard animation was smooth. The streaming was smooth. The problem only showed up at their intersection. That's the kind of bug that costs a week, because nothing is actually broken. Two correct things are simply disagreeing. Here's what was actually happening. While a character reply streams in: Every batch of tokens makes the last bubble taller.On an inverted list, growing the bottom row shifts the content offset.React Native re-runs the layout to absorb the new height.If the keyboard is open, or worse, mid-animation, my keyboard layer is _also_ adjusting offsets at the same time. Two systems are writing to the scroll position on the same frames. The result: the content jumps, the composer twitches, and if the user has scrolled up to re-read an earlier message, the stream yanks them around. Layout thrash. A steady 60fps collapsed into the low teens precisely when the app is supposed to feel most alive, and on a mid-range Android phone, it was worse. TypeScript // The naive streaming append: looks innocent, thrashes layout. // Every chunk triggers a re-measure of the growing bubble, // which fights whatever the keyboard handler is doing this frame. for await (const chunk of stream) { setMessages((prev) => { const next = [...prev]; next[0] = { ...next[0], text: next[0].text + chunk }; // index 0 = newest, inverted list return next; }); } The streaming itself has its own sharp edges, and they compound the layout problem. Two worth calling out before the fix: React Native's fetch can't stream a response body. There's no response.body.getReader() in stock RN. You reach for an SSE polyfill like react-native-sse or if you're on Expo like me, the streaming-capable fetch from expo/fetch. Pick deliberately. This is the single most common thing people get wrong on day one. TypeScript import { fetch } from "expo/fetch"; const res = await fetch(url, { method: "POST", body, signal: controller.signal, }); const reader = res.body.getReader(); const decoder = new TextDecoder(); // ...read loop, parse SSE frames, dispatch tokens Partial markdown will bite you. Tokens arrive mid-syntax. At some frame, your buffer is literally The dragon turned and **stared with the bold marker opened and not yet closed. A naive markdown renderer will either render the asterisks as literal text or flip half the conversation bold. You need a renderer that tolerates unterminated syntax, or you sanitize the buffer before each render. Cancellation has to be real. The user closes the chat, switches characters, or fires off a new message mid-reply. You need an AbortController whose signal actually reaches the fetch. Skip it and you're billed for tokens nobody will read, streamed into a view that already unmounted. The Fix I was about to rewrite my keyboard layer for the fourth time when react-native-keyboard-controller shipped KeyboardChatScrollView in v1.21.0, on March 16, 2026. It is, as far as I can tell, the first component built specifically for the chat-plus-keyboard problem rather than the form-plus-keyboard one, and it happens to solve the streaming case directly. The piece that matters for an LLM app is built on a ClippingScrollView that provides cross-platform contentInset behavior by extending the scrollable geometry rather than recomputing the layout. That one design choice is why the thrash disappears. The keyboard no longer fights the list because absorbing keyboard height is no longer a layout operation. The props read like a tour of every chat app you've used: keyboardLiftBehavior picks how the content reacts to the keyboard. "always" keeps the latest messages visible no matter where you've scrolled (Telegram, WhatsApp). "whenAtEnd" lifts only when you're already at the bottom, and leaves you alone if you've scrolled up to read history (ChatGPT). "persistent" lifts when the keyboard opens and, unlike the rest, stays put when it closes instead of snapping back down (Claude). "never" lets the keyboard cover the content and moves nothing (Perplexity).blankSpace reserves room for an incoming response while absorbing keyboard height. This is the direct antidote to streaming jank. Instead of the list growing reactively frame by frame and fighting the keyboard, you reserve the space up front and let the tokens fill it.extraContentPadding handles a composer that grows as the user types a long message, without jumping the content.freeze locks the layout during emoji and attachment-picker transitions, the other place chat UIs jump. TypeScript import { KeyboardChatScrollView } from "react-native-keyboard-controller"; <KeyboardChatScrollView keyboardLiftBehavior="persistent" // the Claude pattern: lifts on open, stays put on close blankSpace={pendingReply ? estimatedReplyHeight : 0} > {messages.map(renderBubble)} </KeyboardChatScrollView>; On paper whenAtEnd is the tidy answer for a reading-heavy app: don't move the content out from under someone studying an old exchange. I shipped persistent anyway. So many of my users live in assistant apps that Claude's settle-and-stay behavior is just what their hands expect, and familiarity beat theory. Nobody had to relearn how the chat feels. My streaming loop didn't change. What changed is that the loop is now the only thing touching layout while a reply comes in. The keyboard handler stepped out of the fight. The composer stopped twitching. The user who scrolls up to re-read an old exchange stays put while the character keeps talking below the fold. What I'd Keep, and What I'd Throw Away If I were starting Y/N's chat today, I'd delete my hand-rolled keyboard layer without ceremony and start from KeyboardChatScrollView. The custom code I'd keep is the part that was always mine to own: the streaming reader, the partial-markdown guard, and the cancellation plumbing. Those aren't keyboard problems, and no layout library will solve them for you. The general lesson applies well beyond chat. The expensive bug is almost never one broken feature. It's two correct features interacting on the same frame. My keyboard handler was right. My streaming was right. The week disappeared into the seam between them. When something janks and every part tests clean in isolation, stop testing the parts and go look at what they're both writing to. And the smaller, practical one: the chat box is never the easy part of the app. Budget for it like it's a feature, because it is one. For the first time in a while, you don't have to build all of it yourself. If you've solved the Android side of this, or made partial-markdown rendering feel good while streaming, I'd be glad to compare notes in the comments.
Agent framework debates are mostly vibes. One engineer swears LangGraph is faster, another prefers the OpenAI Agents SDK, someone wants Google ADK because it feels future-proof. The team picks one, wires the workflow into its SDK, and the choice is welded in. Changing frameworks later means tearing out the wiring for one SDK and rebuilding the workflow on another, an expensive rewrite few teams take on. This tutorial makes that decision reversible and then settles it with data. You put the agent graph in LaunchDarkly and run four frameworks (LangGraph, Strands, OpenAI Agents SDK, and Google ADK) over the same topology, with the model pinned so the framework is the only variable. A LaunchDarkly experiment ranks them on graph latency and token use, with an LLM judge guarding quality. The results table tells you which framework runs your graph fastest without degrading it. This tutorial is the sequel to Compare AI orchestrators, which ran the same workflow across frameworks but kept the topology in each framework’s code. Here, the topology, routing, models, prompts, tools, and judge all live in LaunchDarkly, and each framework supplies only two functions. The experiment results do more than set a benchmark. The flag that splits experiment traffic also routes production. When one framework wins, you don’t rewrite the app; you change the flag to serve the winner. In a single loop, LaunchDarkly does three jobs: the graph definition, the experiment split, and the runtime control that ships the winner. The workload is a research-gap analysis over a set of arXiv papers. Two readers, approach-analyzer and contradiction-detector, read the same papers in parallel and fan in to gap-synthesizer, which writes the report. Prerequisites A LaunchDarkly account with AgentControl access, and your environment’s SDK keyPython 3.11+ and uvAn ANTHROPIC_API_KEY for the pinned model. OPENAI_API_KEY and GOOGLE_API_KEY are only needed if you run the optional native-model bake-off in Step 9The companion repo: ai-orchestrators on branch tutorial/graph-experiments The Experiment Design The comparison is controlled: same graph, same model, same papers, same judge, with the framework as the only variable. Mechanically, it runs in four stages: Bootstrap. manifest.yaml creates the node configs, graph, orchestrator flag, and judge in LaunchDarkly.Route. On each request, the app evaluates the orchestrator flag to pick a framework: langgraph, strands, openai-agents, or google-adk.Run. The dispatcher runs the shared graph as a directed acyclic graph (DAG). The two readers run concurrently and fan in to the synthesizer.Measure. Each run records how long the graph took, how many tokens it used, and whether the report passed the quality judge. The shape looks like this: ┌──▶ approach-analyzer ───────┐ intake (papers) ─────┤ ├──▶ gap-synthesizer ──▶ report └──▶ contradiction-detector ──┘ Step 1: Create the Graph, Flag, and Judge Everything starts from one file, config/graph_experiment_manifest.yaml. It declares the fetch_paper tool, four node configs (intake plus the three agents, pinned to claude-sonnet-4-5), the graph, the orchestrator flag, and the judge. First, clone the companion repo and install its dependencies with uv: Shell git clone https://github.com/launchdarkly-labs/ai-orchestrators cd ai-orchestrators git checkout tutorial/graph-experiments uv sync Next, set up a LaunchDarkly project. The bootstrap doesn’t create one, so create it with the LaunchDarkly MCP server, the projects agent skill, or the UI. Name it graph-experiments to match the value in .env.example, so the defaults work without edits. When it exists, copy its key into LD_PROJECT_KEY and its production environment SDK key into LD_SDK_KEY in .env. The runners and experiment harness use that SDK key to evaluate the flag and graph. The bootstrap also reads LD_API_KEY from .env to create the resources. Copy the example file to create your .env: Shell cp .env.example .env # then set LD_PROJECT_KEY, LD_SDK_KEY, and LD_API_KEY in .env With the keys in place, run the bootstrap: Shell uv run python scripts/launchdarkly/bootstrap.py config/graph_experiment_manifest.yaml This creates all four node configs, the research-gap-graph, the orchestrator flag (created off), and the gap-quality-judge attached to the gap-synthesizer node (its synthesizer-claude variation, set to 100% sampling). The judge scores the final report against the source papers, so it can verify grounding and citations. A judge can only check based on the information it has, so we give it the papers, not only an upstream agent’s analysis. When the graph ships, it is incomplete by design. The bootstrap creates the contradiction-detector config but wires only intake to approach-analyzer to gap-synthesizer, leaving the detector out. You’ll add it in Step 5 to complete the parallel fan-in. When it finishes, the bootstrap prints a link to your new agent graph. Open it and review the topology before moving on. The graph shows a straight line from intake to approach-analyzer to gap-synthesizer, with contradiction-detector created but not yet wired in. Step 2: The Dispatcher Runs the Graph The dispatcher is the heart of the project, and it’s the same code for every framework. It reads the graph as a DAG, runs the entry nodes concurrently, hands every node the papers as ground truth, and connects the readers at the fan-in node. The only framework-specific pieces are build_agent and invoke, which are passed in as arguments. The whole process is about 100 lines, built on the agent graph traversal methods in the SDK. The complete dispatcher.py is in the companion repo. The dispatcher carries the design in four parts: it builds the execution plan from the graph’s edges, composes each node’s input, runs every ready node concurrently each round, and records the graph’s metrics once per run. First, the dispatcher builds the execution plan from the graph’s edges, so the topology you draw in LaunchDarkly runs: Python for key, node in nodes.items(): for edge in node.get_edges(): target = edge.target_config if target in nodes: succ[key].append(target) preds[target].append(key) Next, every node receives the source papers and any upstream analyses, so each agent and the judge work directly from the source material rather than a summary handed down a chain: Python def compose_input(user_input, predecessor_outputs): parts = [f"=== SOURCE PAPERS ===\n{user_input}"] for key, out in predecessor_outputs: if out and out.strip(): parts.append(f"=== {key} ===\n{out}") return "\n\n".join(parts) Then each round runs every node whose predecessors have finished, concurrently, so the two readers fan out and fan in with no special casing: Python ready = [k for k in pending if all(p in done for p in preds[k])] results = await asyncio.gather(*(run_node(k) for k in ready)) Finally, the dispatcher records the graph’s metrics on each run, including the end-to-end latency the experiment ranks on: Python graph_tracker.track_duration(int((time.monotonic() - start) * 1000)) graph_tracker.track_total_tokens(TokenUsage(input=totals["in"], output=totals["out"], total=totals["in"] + totals["out"])) graph_tracker.track_path(path) graph_tracker.track_invocation_success() The dispatcher reads the topology at runtime, so reshaping the workflow in the UI, adding a node, or redrawing an edge takes effect on the next request with no code change. You’ll do exactly that in Step 5. Step 3: Each Framework Is a Thin Adapter Each framework implements build_agent(node_key, config, instructions) and async invoke(agent, input_text, tracker). Everything dynamic still comes from the LaunchDarkly node config: the model, the attached tools, and the instructions. LangGraph has a LaunchDarkly companion package, so its runner is only a few lines. The companion handles model creation, tool binding, and token tracking, so the adapter holds no framework plumbing of its own: Python def build_agent(node_key, config, instructions): llm = create_langchain_model(config) tools = build_tools(config, TOOL_REGISTRY) # binds only this node's attached tools return create_react_agent(llm, tools, prompt=instructions) async def invoke(agent, input_text, tracker): result = await tracker.track_metrics_of_async( lambda res: LDAIMetrics(success=True, tokens=sum_token_usage_from_messages(res.get("messages", []))), lambda: agent.ainvoke({"messages": [{"role": "user", "content": input_text}]}), ) messages = result.get("messages", []) for message in messages: for name in get_tool_calls_from_response(message): tracker.track_tool_call(name) text = _content_to_text(messages[-1].content) if messages else "" return text, sum_token_usage_from_messages(messages) Strands has no companion package, so its runner builds the model with a small provider-aware factory and binds tools with Strands’ native @tool. The contract is identical: Python def build_agent(node_key, config, instructions): return Agent( name=node_key, model=_create_strands_model(config), system_prompt=instructions or "Process the input and respond.", tools=_bind_tools(config), callback_handler=None, ) OpenAI Agents and Google ADK round out the four. For the comparison to stay fair, all four have to run the same model, but these two SDKs default to their own vendors’ models. LiteLLM, a thin adapter, lets them call any provider, so we point both at the pinned claude-sonnet-4-5 and keep the model identical across all four orchestrators. No OpenAI or Google servers are involved. Instead, LiteLLM translates the request format in-process, and the call goes straight to Anthropic with your key. Google ADK is fully companion-free, and OpenAI Agents uses the ldai_openai companion for token and tool-call telemetry even though it builds the model through LiteLLM. This experiment pins one model across all four frameworks, so every framework here runs Claude. Pointing each framework at its own vendor’s default model instead is a separate, optional exercise, the native-model bake-off in Step 9. The tool callables live in TOOL_REGISTRY, a plain {name: callable} map that each framework binds its own way. Step 4: Smoke Test the Graph Before you run any experiment, confirm the bootstrapped graph runs end to end. First, run one framework: Python uv run python orchestrators/verify_run.py langgraph It prints the path it took and the first part of the report. On the graph as it shipped, the path is intake -> approach-analyzer -> gap-synthesizer: intake runs its short pass, approach-analyzer reads the papers, and gap-synthesizer writes the report. There’s no contradiction-detector yet, and no error. The metrics land in the AgentControl UI under the graph you created. Step 5: Add the Parallel Fan-In In the UI Here’s the payoff of keeping the topology in LaunchDarkly: you finish building the workflow in the UI, with no redeploy, and the running app picks up the new shape on its next request. The contradiction-detector config already exists, with its fetch_paper tool attached. You wire it into the graph to add the second reader and form the parallel fan-in. To complete the graph: Click Agents in the LaunchDarkly sidebar.Click Agent graphs.Select research-gap-graph.Add the contradiction-detector node.Draw an edge from intake to contradiction-detector, then another from contradiction-detector to gap-synthesizer.Click Save. You add no routing logic: the edge itself is the route, because routing is structural. Re-run the smoke test: Shell uv run python orchestrators/verify_run.py langgraph The path now includes contradiction-detector, and because approach-analyzer and contradiction-detector run concurrently, their order can vary. You completed a multi-agent workflow from the UI, and the config you wired in already had its tool attached. You finished a multi-agent workflow from the UI, mid-development, and the dispatcher ran the new shape on the next request. No redeploy, no code change: the graph you draw is the graph that runs. Step 6: Smoke Test All Four Frameworks Before you collect experiment data, make sure all four frameworks can run the completed graph. One command runs all four in sequence: Shell uv run python orchestrators/verify_run.py all It runs each framework against the completed graph and ends with a pass/fail summary, one line per framework, exiting non-zero if any framework failed, so it works as a gate. Each framework prints the path it took and a preview of its report, then a final summary collects the results. A successful run looks like this: Plain Text ▶ Running 'langgraph' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'strands' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'openai-agents' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'google-adk' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer === smoke summary === ✓ langgraph ✓ strands ✓ openai-agents ✓ google-adk If a framework fails, its line shows an ✗ instead of a ✓ and the command exits non-zero. All four smoke tests against the pinned Claude model. ANTHROPIC_API_KEY is the only model key you need, because OpenAI Agents and Google ADK reach Claude through LiteLLM. The OpenAI Agents SDK turns on tracing by default and looks for OPENAI_API_KEY to export traces, so the openai-agents run may print a harmless tracing warning when that key is absent. It doesn’t affect the run. Step 7: Run It Through the Experiment Now you can use a LaunchDarkly experiment to rank the four frameworks on real traffic, on the same graph, with the model held constant. Because the model is fixed, the comparison is operational: which orchestrator delivers the model’s quality fastest, with the least token overhead. The bootstrap already created the flag, the judge, and the graph. These metrics are measured on each request, so do a one-time setup first: Make the request context kind available for experiments.Set the analysis unit of graph latency, tokens, and the judge metric to request. Then create the experiment in the UI: Create an experiment with the orchestrator flag as the treatment.Set the primary metric to Graph latency ($ld:ai:graph:duration:total, the time for a complete graph execution).Add tokens and $ld:ai:judge:gap-quality as secondary metrics.Set the audience to 100% and the randomization unit to request. Each run is a single request, there are no users in this workflow, and request is the unit LaunchDarkly measures AI and graph metrics by.Turn on the orchestrator flag, which the bootstrap created set to off, so it serves the experiment’s variations.Start an experiment iteration. We rank on latency and tokens because, with the model and the graph held constant, those are the things that genuinely differ: a framework can move quality only by degrading the plumbing, like a truncated report or a broken tool call. So $ld:ai:judge:gap-quality stays a guardrail that catches a framework “winning” by cutting corners, not part of the ranking. Swap the model, prompt, or tools later instead of the framework, and that same judge becomes your primary metric. Then drive traffic. The flag assigns each run one framework at random: Shell uv run python scripts/run_experiment.py --runs-per-category 6 That’s six runs over each of the six shipped topics, 36 in total. Assignment is random, so it usually fills all four variations, though it isn’t guaranteed. Each run analyzes the topic’s entire paper set, because gap analysis needs every paper to find real gaps. Open the experiment in LaunchDarkly: latency per variation, with tokens and $ld:ai:judge:gap-quality alongside. The winner is the framework with the best latency and lowest token use that doesn’t let quality slip. Because the model is pinned, cost is a fixed multiple of tokens, so the token column is also the cost ranking; for actual dollar figures, read them from Insights. Because the experiment holds everything but the framework constant, most of these bars land close, often within a few percent, which is by design. In our run, Strands won on speed: it ran the graph fastest, with quality holding at the guardrail. If you optimize for speed and quality holds, that makes Strands the orchestrator to ship for this workload. Six topics and one randomized split isn’t a large sample, so confirm the lead with more topics before you standardize on it. You can do that in Step 9. Step 8: Ship the Winner With Runtime Control The experiment gave you data. The reason to run it in LaunchDarkly, rather than a one-off script, is that acting on that data takes no deploy: the orchestrator flag that was the experiment treatment is also your production router. When a variation wins, stop the iteration and set the flag’s default to that framework. Every request routes to it on the next evaluation, with no redeploy. Then automate what you don’t want to babysit. An adaptive trigger watches a guardrail and changes a flag on its own when production drifts past it. The orchestrator you shipped is operational and won’t degrade by itself, so point the trigger at the model flag from Step 9: it fails over to a backup model when your primary provider has a bad day, the same guardrail driving a different flag. That closes the loop: experiment to find the winner, runtime control to ship it, and automation to keep it healthy. Step 9: Extend the Experiment Tighten the bands by adding more topics. Confidence comes from more distinct topics, not more runs over the same few. Download one with a title-phrase (ti:) query, and the harness picks it up automatically on the next run: Shell uv run python scripts/download_papers.py --query 'ti:"LLM-as-a-judge"' Make quality the headline by flipping a config, not a flag. The framework lives in the orchestrator flag because it is app-level routing, not a property of any agent. The model, the prompt, and the tool set are different: they live in the node configs, so you experiment on the config itself. Add a second variation to a node, such as gap-synthesizer with a stronger model or a tightened prompt, and run an experiment with that config as the treatment and its variations as the arms. Pin the framework by setting the orchestrator flag to one value and leave the graph alone, so the config is the only thing moving. The judge attached to the synthesizer already emits $ld:ai:judge:gap-quality, so quality is the primary metric with no new instrumentation. Now it genuinely moves, because a different model or prompt reasons differently about the same papers. Experiment on the graph shape with a graph-key flag. The dispatcher takes the graph key as an argument, so the shape is another value you can put behind a flag: Python graph_key = ld.variation("graph_shape", context, "research-gap-graph") result = await execute_graph(ai_client, graph_key, context, user_input, build_agent, invoke) Build two graphs with different keys: for example, a linear research-gap-graph-linear (intake to approach-analyzer to gap-synthesizer) against the parallel research-gap-graph, or one with an added critic node against one without. Make a multivariate graph_shape flag whose variations are those graph keys, evaluate it exactly as the app evaluates orchestrator, and set it as the experiment treatment with the framework and model held constant. You are measuring whether the extra structure earns its latency and quality, and because the dispatcher runs whatever shape the key resolves to, no runner or dispatcher code changes. You build the judge once, and it is the guardrail for the framework bake-off, and the headline metric for every model, prompt, tool, and shape you test next. Run a native-model bake-off. This experiment holds the model constant so the framework is the only variable. To compare each framework on its own default model instead, build separate node configs per framework. This is the optional bake-off the prerequisites mention. It’s a follow-up beyond this walkthrough, and the only part that needs OPENAI_API_KEY and GOOGLE_API_KEY. Whatever you flip, follow three rules: Change one variable at a time (the framework, the model, or the shape), never two. If you change more than one, you can’t attribute the win.Keep the quality guardrail on every run, because the fastest variant is often the one that quietly truncated its report or dropped a tool call.Earn confidence with distinct inputs, not repeats: a tight band around three repeated topics is still a tight band around the wrong number. To learn more about judge design, read When to add online evals and Evaluating with LLM-as-judge evaluators. To add a pre-production regression layer, read Offline evaluation of RAG-grounded answers. Recap and Next Steps Framework choice doesn’t have to be a one-way door. Put the topology in a LaunchDarkly agent graph, have each framework supply only build_agent and invoke, and let one experiment settle a question that usually gets answered by whoever argues hardest: pin the model, let the judge guard quality, and pick the orchestrator that delivers it fastest, with evidence in hand. Then keep going, because the framework is only the first swappable component. The same flag, experiment, and judge machinery compares models, prompts, tools, and whole graph shapes the same way, so “which is better” stops being a debate and becomes a measurement. And because the experiment and the runtime control are one flag, you never stop at a finding: you ship it, ramp it with a progressive rollout, and let an adaptive trigger hold the line in production while the AI iteration loop for reliable agents keeps the next change shipping behind eval gates. The complete code is in the sample repo. Get started with AgentControl, point the four frameworks at a graph your team actually runs, and settle the next framework argument with a number instead of a hunch.
On one of our projects, we were building microfrontends, and at some point we wanted to add SSR. The reasons were the usual ones: better first paint, fewer layout shifts, real content for crawlers, less JS to load before something appears on screen. Setting it up turned out to be harder than I expected. There was no obvious out-of-box path that fit our setup, and most of the approaches I found either assumed a shared build or asked us to add new infrastructure on top of what we already had. That is what made me start sketching a small package. Something any team could drop in and get SSR for their microfrontend without rewriting either side. The result is @mf-toolkit/mf-ssr. The rest of this is about the approach behind it, since I think that is the interesting part. What I Wanted I started from a short list, taken straight from how I'd want to use such a thing: MF content on first paint. The remote's HTML should arrive inside the host's server response, not be fetched from the client after JS loads. No empty slot, no layout shift, real content in crawlers.No shared build, no central orchestrator. Each team builds and deploys their remote on their own schedule. The host should not need a special Node process that imports every remote into one bundle, and remote teams should not need to rewrite their bundler config to fit a central setup.Two paths for two setups, one host component. I wanted both scenarios covered. url mode for when the remote team runs their own server and wants to own SSR on their side (and possibly use a non-React framework). loader mode for when the remote only ships a static React bundle and the host server can do the SSR for it. The host code should look almost the same in either case, with just a single prop telling the component which path to use.Any framework, any runtime. The remote might be React, but it could be Vue, Svelte, or anything else. The host shouldn't care. And on the server, the same code should run on Node, Bun, Cloudflare Workers, or Vercel Edge with no rewrites.Host state still drives the remote after hydration. When the host re-renders with new props, the remote should re-render too. No re-fetch, no re-mount, no shared store between bundles.Honest failure modes. A timeout when the remote is slow, retry when a request fails, an explicit fallback for total failure, and a cache that respects auth boundaries. The things that decide whether SSR is a win or a regression when one team has a bad deploy. The last bullet is what most articles skip. SSR is easy in the happy path. The interesting code is what happens when one of the remotes is slow, down, or returning garbage. How It Works The idea is small: Instead of importing remote components into the host server, the host pulls the rendered output in over HTTP at SSR time and streams it into its own response. The browser gets a full page on first paint. How that "pull" happens depends on how the remote is deployed. The package supports two modes for that: url mode – the remote has its own HTTP endpoint that returns rendered HTML. The host fetches that HTML during SSR.loader mode – the remote is a static React bundle on a CDN or S3, no server behind it. The host imports the component directly during SSR and renders it inline. Same host component (<MFBridgeSSR>) in both cases, just one prop changes. Both modes can live on the same page. The interesting part is what happens after hydration. The host has to push prop changes into the remote without re-fetching anything. I will get to that in a moment. I'll start with url mode since it is the more general case (any framework on the remote side, any runtime on the server), and then cover loader mode separately. url mode: Remote With Its Own HTTP Endpoint In url mode, the remote server does the SSR. The remote team runs their own runtime (Node, Bun, a Cloudflare Worker, a Next.js Route Handler, whatever they prefer) and exposes an HTTP endpoint that returns rendered HTML for the given props. The host's SSR pass just calls that endpoint and inlines the response into the page. Each microfrontend owns its own rendering pipeline. Remote Handler TypeScript-JSX import { createMFReactFragment } from '@mf-toolkit/mf-ssr/fragment' import { CheckoutWidget } from './CheckoutWidget' export const handler = createMFReactFragment(CheckoutWidget) handler is a plain Web fetch handler: (req: Request) => Promise<Response>. It reads props from the query string, renders the component to a stream with renderToReadableStream, and writes the props into a small <script> tag so the client can hydrate without going back to the network. One nuance worth flagging: those props go inside a <script> tag, so a raw </script> inside a string prop would close the tag prematurely and let user-controlled values escape into the HTML context. The handler escapes <, >, &, and U+2028/U+2029 to their \uXXXX equivalents before embedding. JSON.parse on the client treats them the same as the originals, but the browser's HTML parser never sees a closing tag. It is a few lines of code that close a real XSS hole. You wire the handler into whatever HTTP framework the remote team already uses. Hono, a Next.js Route Handler, Bun, plain Node, a Cloudflare Worker. The handler doesn't know about any of them. And because the whole thing is Web Streams, it runs on Cloudflare Workers, Vercel Edge, Bun, and Node 18+ without changes. Non-React Remotes createMFReactFragment is a React-only helper. If the remote is Vue, Svelte, Solid, or vanilla JS, the team writes their own fetch handler instead, but it has to produce the same HTML shape the host expects: TypeScript-JSX <div data-mf-ssr="checkout"> <script type="application/json" data-mf-props>{"orderId":"42"}</script> <div data-mf-app><!-- Vue / Svelte / whatever rendered HTML --></div> </div> The team uses their framework's SSR renderer (renderToString for Vue, Svelte's SSR API, and so on) to produce the inner HTML, and serializes props into the <script data-mf-props> tag, applying the same < / > / & escaping. On the client, the remote mounts itself into [data-mf-app] and reads initial props from [data-mf-props]. If it needs prop updates from the host after hydration, it listens on the same DOMEventBus (exported from @mf-toolkit/mf-bridge). The bus is a thin wrapper over native CustomEvent, with no React dependency, so it works fine for any framework. This path is more work than createMFReactFragment, but the contract is small and explicit. The host doesn't care which framework produced the inner HTML — as long as the wrapper structure matches, hydration finds the right slots. Host Component TypeScript-JSX <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId, step } fallback={<CheckoutSkeleton />} /> During SSR, the host fetches the remote's HTML and streams it into the response. Each <MFBridgeSSR> lives in its own Suspense boundary, so a slow checkout doesn't block the header. They stream as they resolve. On the client, the host hydrates, then waits for prop changes coming from React. Prop Updates After Hydration This was the part I cared about most. The remote is in its own React root, often in its own bundle, sometimes in a completely different framework. You can't re-render it like a normal child. So I used the one thing both sides already share at runtime: the DOM node the remote is mounted into. When the host re-renders with new props, the host fires a CustomEvent on that node. The remote listens for it and re-renders its root with the new props. No re-fetch, no global state, no coupling between bundles beyond a shared namespace string. TypeScript-JSX // remote client entry import { hydrateWithBridge } from '@mf-toolkit/mf-bridge/hydrate' import { CheckoutWidget } from './CheckoutWidget' hydrateWithBridge(CheckoutWidget, { namespace: 'checkout' }) I picked this because it is isolated by construction. If a page has several MF slots, each one has its own mount node, so events never leak between them. And it is just DOM, so there is no bundler magic to debug when something goes wrong. Events and Commands Prop streaming is one direction. For the other direction, the same bus works in reverse. The host passes onEvent to receive events the remote emits, and a commandRef it can use to send imperative commands back: TypeScript-JSX const resetRef = useRef<((type: string, payload?: unknown) => void) | null>(null) <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId } onEvent={(type, payload) => { if (type === 'orderPlaced') navigate('/thanks') } commandRef={resetRef} /> // somewhere in host code, e.g. when the user switches accounts: resetRef.current?.('reset') On the remote, hydrateWithBridge accepts an onCommand handler, and DOMEventBus (exported from @mf-toolkit/mf-bridge) lets the remote send events back: TypeScript-JSX import { hydrateWithBridge } from '@mf-toolkit/mf-bridge/hydrate' import { DOMEventBus } from '@mf-toolkit/mf-bridge' hydrateWithBridge(CheckoutWidget, { namespace: 'checkout', onCommand: (type) => { if (type === 'reset') store.reset() }, }) // inside the widget, after a successful payment: const container = document.querySelector<HTMLElement>('[data-mf-namespace="checkout"]')! new DOMEventBus(container, 'checkout').send('event', { type: 'orderPlaced', payload: { orderId }, }) The channel is the same DOMEventBus, just with extra event names on top of propsChanged. So everything I said earlier about isolation still holds: events on one slot don't reach another, even when the remote is the same. loader mode: Remote as a Static Bundle In loader mode, the host server does the SSR for the remote. The remote team ships only a static React bundle (CDN, S3, or a Module Federation host) and runs no server of their own. When the host renders its page server-side, it imports the remote component and renders it inline, the same way it renders any other component in the host tree. The remote has no SSR runtime and no rendering responsibility; the host does all the work.ё Host Component JSX const loadCheckout = () => import('checkout/Widget').then(m => m.CheckoutWidget) <MFBridgeSSR loader={loadCheckout} props={{ orderId, step } fallback={<CheckoutSkeleton />} /> That is everything. No namespace, no errorFallback tricks needed for hydration, no client entry to write on the remote side. The package wraps the loader in React.lazy and renders the component inside the host's React tree, both server-side and after hydration. Props, Events, Commands Since the remote lives inside the host's React tree, every kind of communication is just React: Props – re-render normally. When the host's parent component re-renders with new props, the remote re-renders too. No DOMEventBus, no hydrateWithBridge, no propsChanged events.Events from remote to host – pass a callback through props. The remote calls it like any other handler.Commands from host to remote – pass them through props as well, or expose a ref through forwardRef. If you find yourself wanting onEvent / commandRef here, you are probably reaching for url mode. Requirements A few constraints come with this mode: Host must be able to resolve the loader on the server. The package calls your loader() function as-is. It doesn't fetch bundles from URLs itself. In practice, this means Module Federation runtime on the host (or some other server-side dynamic import mechanism that knows how to find checkout/Widget). Without that, the import fails in Node before any rendering happens.React only. The host literally calls the component during SSR, so the remote has to be a React component. For Vue/Svelte/vanilla remotes, use url mode.SSR-safe import. The remote's exposed module has to be importable on the server, which means no window, document, or other browser globals at the module top level. Move that code inside useEffect or behind a typeof window check.Stable loader reference. Define loadCheckout at module scope or wrap it in useCallback. The package caches the resulting React.lazy by loader reference so Suspense retries reuse the same promise. A new function on every render would break that and trigger an infinite retry loop. When to Pick Which CategoryURL modeLoader modeRemote infrastructureOwn HTTP endpoint: Node.js, Bun, Worker, etc.Static bundle on CDN, S3, or Module Federation hostRemote frameworkAny: React, Vue, Svelte, vanilla JavaScriptReact onlyIsolationSeparate React root inside the remote bundleRendered inline in the host React treeProp updatesDOM events through DOMEventBusNative React re-renderEvents and commandsonEvent and commandRefReact props and refsBest forIndependent teams, mixed frameworks, and polyreposSimple React remotes with no extra infrastructure Both modes use the same <MFBridgeSSR> and can be mixed freely on the same page. The Corner Cases I Spent Time On A few production scenarios I wanted to make sure the package handled honestly. Graceful Degradation When the Remote Is Down A remote can be slow, return a 5xx, or simply not respond. The host page shouldn't break because of one bad slot. mf-ssr accepts an errorFallback, and the trick is that the fallback can be the same remote mounted on the client through mf-bridge: TypeScript-JSX import { MFBridgeSSR } from '@mf-toolkit/mf-ssr' import { MFBridgeLazy } from '@mf-toolkit/mf-bridge' <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId } timeout={2000} errorFallback={ <MFBridgeLazy register={() => import('checkout/entry').then(m => m.register)} props={{ orderId } fallback={<CheckoutSkeleton />} /> } /> If the SSR fetch times out, the user still gets the widget. Just on the client, the same way it would have worked without mf-ssr at all. The page doesn't break. The slot loses its first-paint optimization, for that one request. When the remote recovers, the next render uses SSR again with no code change on either side. I like this case because it inverts the usual SSR-or-nothing tradeoff. SSR becomes the fast path, with a working client-side path sitting right behind it. Auth-Isolated Caching The host caches fragments by url + props + timeout. Fine for public content. Not fine when each user gets different HTML — they would share a cache slot and see each other's pages. So there is a cacheKey prop you set when the request carries auth: TypeScript-JSX <MFBridgeSSR url="https://account.acme.com/fragment" namespace="account" props={{ view: 'orders' } fetchOptions={{ headers: { authorization: `Bearer ${token}` } } cacheKey={userId} /> The other side of the same coin is public fragments. The remote's fragment endpoint accepts a cacheControl option, so you can serve a product card as public, s-maxage=60, stale-while-revalidate=30 and let a CDN cache it for everyone: TypeScript-JSX export const handler = createMFReactFragment(ProductCard, { cacheControl: 'public, s-maxage=60, stale-while-revalidate=30', vary: 'Accept-Language', }) One pattern handles per-user fragments, the other handles cacheable public ones. Same component on both sides. Multiple Instances of the Same Remote Header, sidebar, and a content slot can all be the same remote on one page. The reason I sent prop updates through the mount DOM node, instead of a global event bus, is exactly this case: each <MFBridgeSSR> has its own DOM node, so events stay scoped to it. No filtering by instance id, no manual subscription bookkeeping. Warming the Cache From RSC If you know a fragment is going to be needed, you can start the fetch before <MFBridgeSSR> even renders. Suspense then skips the fallback entirely: TypeScript-JSX import { preloadFragment } from '@mf-toolkit/mf-ssr' // In a Server Component or route loader preloadFragment('https://checkout.acme.com/fragment', { orderId }) By the time the component renders down the tree, the HTML is already there. Where It Fits If your microfrontends share one build (a single bundler config that imports every remote), you don't need any of this. Use whatever your framework gives you. mf-ssr is for the case where each team builds and deploys independently. Different repos or not, the point is that there is no shared build step pulling everything into one Node process — and you still want a full page on first paint. The bet is that HTTP is a good enough boundary between teams, and that DOM events are a good enough way to keep host state in sync with remote rendering after hydration. The CSS isolation question, by the way, lives in mf-bridge, not here: it has shadowDom and adoptHostStyles props that wrap the remote in a Shadow DOM and forward host stylesheets (including Tailwind / CSS-in-JS chunks injected after mount) into the shadow root. SSR fragments don't use it by default since the HTML is inlined into the host response, but the option exists if you want it. Try It The package is published as @mf-toolkit/mf-ssr. The repo has runnable examples, and I've also made a demo repo where you can play with all my tools. If you've solved the same problem in a different way, I'd be curious to compare notes.
Cloud migration projects almost always treat security as a downstream concern something to bolt on after workloads have already moved, once the “real” migration work is done. Across dozens of enterprise migrations spanning finance, healthcare, and manufacturing workloads, that ordering is consistently the source of the costliest rework: reopened firewall rules, retrofitted identity models, and access reviews that should have happened before a single virtual machine was provisioned. The pattern holds regardless of which cloud provider is on the receiving end. What follows is a framework provider-agnostic by design for embedding zero-trust principles into the migration process itself, rather than applying them after the fact. Why Bolt-On Security Fails Traditional migration playbooks are organized around workload movement: discover, assess, re-platform, cut over, optimize. Security tasks are usually inserted late, as a checklist item before go-live. Three consequences follow reliably: Implicit trust survives the move. Implicit trust survives the move. On-premises networks often rely on perimeter trust: anything inside the firewall is assumed safe. When that assumption is lifted-and-shifted into the cloud without redesign, the perimeter simply becomes larger and harder to defend.Identity sprawl compounds. Identity sprawl compounds. Migrations frequently multiply service accounts, temporary roles, and cross-environment credentials used to bridge on-prem and cloud during cutover. Few of these get cleaned up.Retrofitting is expensive. Retrofitting is expensive. Segmenting a network or re-scoping IAM roles after hundreds of workloads are already live requires downtime windows and change approvals that could have been avoided by designing correctly the first time. The Framework: 4 Pillars, Applied in Migration Order The framework below organizes zero-trust adoption into four pillars, sequenced to match the natural phases of a migration rather than treated as a parallel workstream. 1. Identity as the New Perimeter Before any workload assessment begins, establish the identity model the migrated environment will use, not the one the source environment happens to have. Define role-based access aligned to job function, not to legacy group membership inherited from the source directory.Require multi-factor authentication for every administrative path into the target environment before migration tooling is granted access, not after.Treat every migration-tooling service account as temporary by default, with an explicit expiration and re-certification date. 2. Segment Before You Migrate, Not After Network segmentation decisions made during the assessment phase are cheap. The same decisions made post-migration require change windows and stakeholder sign-off. Group workloads into trust tiers during discovery (e.g., internet-facing, internal-only, regulated-data) rather than assuming a flat network topology will be corrected later.Design micro-segmentation boundaries around workload tiers before the first server moves, so that day-one network policy already reflects least-privilege communication paths.Validate east-west traffic rules against actual application dependency maps, not assumed ones; dependency mapping tools exist for this precisely because assumptions are usually wrong. 3. Encrypt and Verify at Every Hop, Not Just at Rest Most cloud providers make encryption at rest close to a default setting. The gap is almost always in transit and in verification. Require mutual TLS or equivalent between service-to-service calls introduced during migration, especially temporary bridging connections between source and target environments.Treat data classification as a migration input, not a post-migration audit finding. Classify before you move, so encryption and access policy can be applied by tier from day one.Build verification checkpoints into the cutover plan itself: an environment isn't “migrated” until its access logs confirm no implicit-trust paths remain from the legacy network. 4. Assume Breach, Instrument Accordingly The final pillar is operational rather than architectural: build the assumption of compromise into monitoring from the start of the migration, not after an incident. Instrument logging and alerting for the target environment before cutover, so that abnormal access patterns are visible from hour one rather than backfilled weeks later.Run tabletop exercises against the migrated architecture; specifically, lessons from the legacy environment's incident response plan rarely transfer cleanly.Track a small set of leading indicators (privileged session anomalies, unexpected cross-tier traffic, credential reuse across environments) rather than waiting for a full SIEM rollout to catch up. Lessons From Enterprise Deployments A few patterns show up consistently across large, regulated deployments: Sequencing beats scope. Organizations that tried to implement all four pillars simultaneously across an entire estate stalled. The deployments that succeeded phased identity and segmentation first, then layered encryption verification and monitoring in as workloads landed.Legacy exceptions need sunset dates. Legacy exceptions need sunset dates. Every migration produces temporary trust exceptions to keep the business running during cutover. Without a hard expiration date attached at creation, these exceptions become permanent attack surface.Cross-functional ownership matters more than tooling. Cross-functional ownership matters more than tooling. The deployments with the fewest post-migration security incidents were the ones where network, identity, and application teams jointly signed off on the trust model before migration started, not the ones with the most sophisticated tooling. Common Pitfalls Treating zero trust as a product purchase rather than an architectural discipline applied throughout the migration lifecycle.Migrating identity and network configuration as-is with the intention to “harden it later” rarely comes without an incident forcing it.Measuring migration success purely on workload count and timeline, with security posture reviewed only at the end. Closing Thought Zero trust and cloud migration are often treated as separate initiatives running on separate timelines. The organizations that get the best outcomes fewer post-migration incidents and faster time-to-secure-operations are the ones that treat zero trust as a design constraint on the migration itself, sequenced into discovery, assessment, and cutover rather than appended afterward. The framework above is intentionally provider-agnostic because the discipline it describes identity first, segmentation before movement, verification at every hop, and instrumentation from day one holds regardless of which cloud the workloads land on.
A few days ago, I set out to build a simple image classification model using convolutional neural networks (CNNs). The task itself wasn’t particularly complex, but choosing the right framework proved more challenging than expected. I found myself choosing between TensorFlow and PyTorch, two powerful frameworks for building high-performance CNNs. To explore this, I implemented the same CNN in both frameworks under identical conditions and compared them across key aspects like learning curve, flexibility, debugging, and performance. A Quick Look at the Frameworks Before deep-diving into the comparison, it’s worth briefly understanding the two frameworks used throughout this experiment. 1. TensorFlow TensorFlow is an open-source deep learning framework developed by Google. It is widely known for its strong ecosystem and production-ready capabilities. One of its key strengths is its integration with high-level APIs such as Keras, which simplifies model building and training. TensorFlow is commonly used in large-scale applications, offering tools for deployment across web, mobile, and edge devices. Overall, it is often preferred when moving models from experimentation to production environments. 2. PyTorch PyTorch is an open-source deep learning framework developed by Meta Platforms. It has gained significant popularity, especially in the research community, due to its simplicity and flexibility. PyTorch uses a dynamic computation graph, which makes it feel more like standard Python code. This makes model development more intuitive and debugging significantly easier. It is often the preferred choice for experimentation, rapid prototyping, and research-driven projects. Experiment Setup To ensure a fair and meaningful comparison between TensorFlow and PyTorch, both implementations were designed under identical conditions. 1. Dataset The models were trained and evaluated on the CIFAR-10 dataset, a widely used benchmark for image classification tasks.It consists of 60,000 color images across 10 classes, making it suitable for evaluating CNN performance.CIFAR-10 is publicly available for research purposes and is commonly distributed under a permissive academic license, allowing free use for educational and non-commercial applications. 2. Model Architecture A simple yet effective Convolutional Neural Network (CNN) architecture was used in both frameworks. The structure includes: Convolutional layers for feature extractionReLU activation functionsMax-pooling layers for dimensionality reductionFully connected layers for classification Care was taken to ensure that the architecture remained identical in both implementations. 3. Training Configuration To maintain consistency, the following hyperparameters were used across both frameworks: Optimizer: AdamLearning rate: 0.001Batch size: 64Number of epochs: 10Loss function: Cross-Entropy Loss 4. Environment All experiments were conducted using Google Colab. Both TensorFlow and PyTorch implementations were executed in the same runtime environment. The configuration used includes: Runtime Type: GPU-enabled environmentPython Version: 3.xDeep Learning Libraries: TensorFlow and PyTorch (latest stable versions) The experiments were run on the same Colab runtime session to maintain consistency in resource allocation. Implementation To ensure a fair comparison, the same CNN architecture and training configuration were implemented using both TensorFlow and PyTorch. While the underlying model remains identical, the implementation approach differs significantly across the two frameworks. 1. CNN Implementation in TensorFlow The model was first implemented using TensorFlow with its high-level Keras API, which provides a concise and structured way to define deep learning models. Model Definition Python model = models.Sequential([ layers.Conv2D(32, (3,3), activation='relu', input_shape=(32,32,3)), layers.MaxPooling2D((2,2)), layers.Conv2D(64, (3,3), activation='relu'), layers.MaxPooling2D((2,2)), layers.Flatten(), layers.Dense(64, activation='relu'), layers.Dense(10, activation='softmax') ]) The Sequential API allows layers to be stacked in a linear fashion, making the architecture easy to read and implement. This significantly reduces boilerplate code and is especially helpful for beginners. Model Compilation and Training Python model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) history = model.fit(x_train, y_train, epochs=10, batch_size=64, validation_data=(x_test, y_test)) Training in TensorFlow is handled using a single high-level function. It automatically manages the training loop, backpropagation, and metric tracking, making the process highly streamlined. Observation: TensorFlow offers a compact and beginner-friendly implementation. With minimal code, it handles most of the underlying complexity, making it ideal for rapid development and production-oriented workflows. 2. CNN Implementation in PyTorch The same CNN architecture was implemented using PyTorch, which follows a more explicit and flexible approach. Model Definition Python class CNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 32, 3) self.pool = nn.MaxPool2d(2,2) self.conv2 = nn.Conv2d(32, 64, 3) self.fc1 = nn.Linear(64*6*6, 64) self.fc2 = nn.Linear(64, 10) In PyTorch, models are defined using Python classes. This provides greater flexibility but requires a more detailed understanding of how each component works. Forward Pass Python def forward(self, x): x = self.pool(torch.relu(self.conv1(x))) x = self.pool(torch.relu(self.conv2(x))) x = x.view(-1, 64*6*6) x = torch.relu(self.fc1(x)) x = self.fc2(x) return x The forward pass must be explicitly defined, giving full control over how data flows through the network. This makes it easier to customize and debug complex models. Training Loop Python for inputs, labels in trainloader: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() Unlike TensorFlow, PyTorch requires a manual training loop. While this increases the amount of code, it also provides complete transparency and control over the training process. Observation: PyTorch offers a more flexible and transparent approach. Although it requires more code, it allows finer control over model behavior, making it a preferred choice for experimentation and research. With both implementations in place, the next step is to evaluate their performance and analyze how they compare across different metrics. Results and Analysis With both implementations completed under identical conditions, we now compare TensorFlow and PyTorch using empirical results and practical observations. 1. Accuracy The image illustrates the Accuracy and Training Time for TensorFlow and PyTorch. (Image by Author) Both frameworks achieved nearly identical performance on the CIFAR-10 dataset: TensorFlow Accuracy: 68.78% PyTorch Accuracy: 68.95% The difference (0.17%) is extremely small and falls within normal training variation. When architecture, data, and hyperparameters are controlled, the choice of framework has virtually no impact on model accuracy. Additionally, both models show: Consistent improvement across epochsNo signs of severe overfittingStable generalization on test data The image illustrates the Train and Test accuracy for TensorFlow and PyTorch. (Image by Author) 2. Loss Convergence The image illustrates the Loss Convergence for TensorFlow and PyTorch in Logarithmic Scale. (Image by Author) TensorFlow exhibits a smooth and gradually decreasing loss, both for training and validation.PyTorch shows a similar downward trend, but with slightly larger values. The higher loss values in PyTorch are due to loss accumulation across batches, whereas TensorFlow reports average loss per epoch. Despite differences in scale, both frameworks demonstrate stable and consistent convergence behavior, indicating effective training. 3. Model Training Performance Training Speed TensorFlow: 715.23 secondsPyTorch: 723.31 seconds TensorFlow is slightly faster (~1% difference), but the gap is minimal For moderate-sized datasets like CIFAR-10, training speed differences are negligible and unlikely to influence framework selection, but TensorFlow provides strong tooling for large-scale deployment, while PyTorch is equally capable in training large models. 4. Scalability and Flexibility TensorFlow follows a more structured and predefined approach, but provides robust tools such as distributed training and deployment pipelines. It also holds an advantage in large-scale production environments, while PyTorch continues to close the gap. PyTorch uses a dynamic computation graph, allowing runtime modifications, which makes custom modifications easy. It is better suited for research and experimentation, where flexibility is critical. 5. Learning Curve From an implementation standpoint: TensorFlow (via Keras) allows model creation with minimal and structured code; hence, it is easier to start with.PyTorch requires explicit definitions for model architecture, forward passes, and training loops; this results in lengthier code and greater initial effort. Ultimately, the choice between TensorFlow and PyTorch is less about performance and more about how you prefer to design, experiment with, and deploy deep learning models. Choosing Between TensorFlow and PyTorch TensorFlow is better suited when working on production-ready systems, where scalability, deployment tools, and a structured workflow are important. Its high-level APIs make it easy to develop models quickly and integrate them into real-world applications, including mobile and edge environments.PyTorch is more appropriate for research and experimentation, where flexibility and control are critical. Its dynamic nature and seamless debugging experience make it ideal for testing new ideas and building custom architectures. Conclusion: Choosing the Right Framework Through this hands-on comparison of TensorFlow and PyTorch using a CNN on the CIFAR-10 dataset, one key insight becomes clear: both frameworks perform almost identically when it comes to core metrics. The experimental results showed: Nearly identical accuracy (~68–69%)Comparable training timesSimilar loss convergence patterns This highlights an important takeaway: The choice of framework has little to no impact on model performance when architecture and training conditions are kept consistent. However, the real difference lies not in performance, but in how you build, debug, and deploy models. Ultimately, the best framework is not the one that performs slightly better on benchmarks, but the one that aligns with your workflow, problem domain, and development style. Connect with me for more updates: MediumLinkedIN
The functional programming answer, to those who wonder how to integrate or combine it with object-oriented programming, is usually: Turtles all the way down. This is an aphorism whose origin is credited to Richard Feynman. In his book, Surely You're Joking, Mr. Feynman !, published in 1985, he tells the story of one of his conferences on the nature of the universe, where he was challenged by someone in the audience, saying that the universe rests on a turtle. Feynman asked then what the turtle is resting on, and the answer was: "another bigger turtle". And when he smugly asked what the bigger turtle is resting on, the attendee said: "It's turtles all the way down, you can't trick me !" This metaphor is often used in the context of functional programming to describe an infinite series of entities governed by a recursive principle. And it's also the answer of functional programming to developers coming from an object-oriented mindset: "just do functional all the way down." But to adopt a more systematic approach to combining object-oriented principles with a functional style, a more practical answer is required, and this is what I'm trying to do here. We, as developers, fortunately don't have to reinvent the wheel. All the problems are solved nowadays, especially since LLM agents became the most common digital infrastructure. But as surprising as it might seem to our younger colleagues, who can't live 48 hours without AI, even before LLMs, a general approach fitting solutions to problems existed, in the form of design patterns. As a matter of fact, object-oriented programming proposes repeatable solutions tested, proven, and formalized, called design patterns, that you most likely already used, even if you aren't aware of it. The Gang of Four classified these patterns into three groups: Behavioral patterns, which deal with responsibilities and communication between objects.Creational patterns that abstract the object creation/instantiation process.Structural patterns that compose objects such that they form larger or enhanced ones. Let's take some of the most commonly used patterns in each category and see how to combine their object-oriented inherent nature with a more functional approach. The Factory This design pattern belongs to the creational category, and its purpose is to instantiate objects without exposing implementation details. The Object-Oriented Approach The figure below shows the class diagram of a factory design pattern: Our scenario here is a simple one: a Product interface implemented by three classes: BookProduct, ElectronicProduct and FashionProduct. They can be created through the ProductFactory class, as follows: Java public class ProductFactory { public static Product newProduct (String name, String description, BigDecimal price, ProductType productType) { Objects.requireNonNull(name, "Name is null"); ... return switch (productType) { case BOOK -> new BookProduct(name, description, price); case ELECTRONIC -> new ElectronicProduct(name, description, price); case FASHION -> new FashionProduct(name, description, price); default -> throw new IllegalArgumentException ("Unknown type: %s".formatted(productType)); }; } } Using this factory, it's very easy to create a BookProduct, for example, while avoiding to expose implementation details: Java ... Product product = ProductFactory.newProduct("Book1", "A book", new BigDecimal("20.50"), ProductType.BOOK); ... As you probably noticed, the ProductType enumerated defines the three categories. If a new product is to be introduced, the factory has to be modified to reflect this business change. And this interdependence of the factory and the enumerated makes the whole approach fragile. In order to reduce this fragility, we need to introduce a compile-time validation with a more functional approach. The Functional Approach Our example is an over-simplified case of a product management system. The presented factory instantiates different simple records having the same arguments. These identical constructors give us the possibility to move the factory directly into the ProductType enumerated, such that any new product automatically requires a corresponding factory. Java enum types are based on constant names, but we can attach to each one its corresponding value. Or, even better, a factory function for creating discrete products. Look at that: Java public enum ProductType { ELECTRONIC(ElectronicProduct::new), FASHION(FashionProduct::new), BOOK(BookProduct::new); public final TriFunction<String, String, BigDecimal, Product> factory; ProductType (TriFunction<String, String, BigDecimal, Product> factory) { this.factory = factory; } public Product newInstance (String name, String description, BigDecimal price) { Objects.requireNonNull(name, "Name is null"); ... return this.factory.apply (name, description, price); } } Now, creating a new Product instances is easier: Java Product product = ProductType.BOOK.newInstance("Book1", "A book", new BigDecimal("20.45")); The public property factory seems redundant now that a dedicated method for the instance creation is available. But it provides a very convenient functional way to interact further with the factory. For example: Java ProductType.BOOK.factory.andThen(showThePrice).apply("Book1", "A book", new BigDecimal("20.45")); as shown in the TestProductFactory class, in the fp_design_paterns.factorypackage. Of course, given that our products need three-argument constructors and since Java doesn't provide an equivalent of the BiFunction class, but with three input arguments, you will need to craft a TriFunction class, as shown below: Java @FunctionalInterface public interface TriFunction<A, B, C, R> { R apply(A a, B b, C c); default <K> TriFunction<A, B, C, K> andThen(Function<? super R, ? extends K> f) { Objects.requireNonNull(f); return (A a, B b, C c) -> f.apply(apply(a, b, c)); } } You can do that or, if like me, you prefer to use a reliable library, then Vavr already defines a Function3 interface that has the behavior you want. Just include the following Maven dependency: XML <dependency> <groupId>io.vavr</groupId> <artifactId>vavr</artifactId> <version>1.0.1</version> </dependency> This library is a good choice if you need to define functions with up to 8 arguments. Then, you just need to replace, in ProductType, the following definition: Java public final TriFunction<String, String, BigDecimal, Product> factory; ProductType (TriFunction<String, String, BigDecimal, Product> factory) { this.factory = factory; } by this one: Java public final Function3<String, String, BigDecimal, Product> factory; ProductType (Function3<String, String, BigDecimal, Product> factory) { this.factory = factory; } The Visitor This design pattern belongs to the behavioral category and its purpose is to add new operations to an existing object hierarchy without modifying the classes of that hierarchy. It is the classic answer to the expression problem: When the set of types is stable, but the set of operations grows, the Visitor lets you keep adding operations cheaply. We reuse the same domain as the factory: a Product implemented by BookProduct, ElectronicProduct and FashionProduct. To give the visitor a reason to exist, each operation now behaves differently per product type: VAT: a reduced 5.5% rate for books, the standard 20% rate otherwise.Shipping: 10.00 + 2% of the price for (fragile, insured) electronics, a flat 3.00 for books and a flat 5.00 for fashion.Discount: 10% for electronics, 5% for books, 15% for fashion. The Object-Oriented Approach The classic Visitor relies on double dispatch. Each Product accepts a visitor and calls back the overload matching its own type: Java public interface Product { ... <R> R accept(ProductVisitor<R> visitor); } public record BookProduct (String name, String description, BigDecimal price) implements Product { ... public <R> R accept(ProductVisitor<R> visitor) { return visitor.visit(this); } } The operation lives in a generic visitor, one `visit` overload per concrete type: Java public interface ProductVisitor<R> { R visit(ElectronicProduct product); R visit(BookProduct product); R visit(FashionProduct product); } Computing the VAT of any product is then a matter of applying a concrete visitor: Java BigDecimal vat = book.accept(new VatVisitor()); Adding a new operation (shipping, discount, ...) only requires a new ProductVisitor implementation as the Product implementation classes never change. This is the reverse of the trade-off the factory made: it made adding a new operation easy, but a new product type is more expensive to add as you must edit its central switch. The visitor makes adding a new operation free but shifts that same cost onto types, since a new product type now forces every visitor to be updated. It is the classic expression problem: you can make types cheap to add or operations cheap to add, but not both. The following figure shows the object-oriented implementation class diagram: The Functional Approach Look now at the class diagram of the Visitor functional style implementation: In modern Java, the functional counterpart of the Visitor is exhaustive pattern matching over a sealed type. We first seal the hierarchy: Java public sealed interface Product permits ElectronicProduct, BookProduct, FashionProduct { ... } An operation is then just a Function<Product, R> built on a switch that deconstructs each record. Because Product is sealed, the compiler proves the switch is exhaustive — no default branch, no double dispatch, no accept: Java public static final Function<Product, BigDecimal> VAT = product -> switch (product) { case BookProduct(String name, String description, BigDecimal price) -> amount(price, "0.055"); case ElectronicProduct(String name, String description, BigDecimal price) -> amount(price, "0.20"); case FashionProduct(String name, String description, BigDecimal price) -> amount(price, "0.20"); }; Being ordinary functions, these operations compose: Java ProductOperations.DISCOUNT.andThen(amount -> "discount=" + amount).apply(fashion); Between the classic Visitor and pure pattern matching sits an intermediate step: the visitor as a bundle of functions, one lambda per type, instead of an interface with one method per type: Java public record ProductVisitor<R>( Function<ElectronicProduct, R> onElectronic, Function<BookProduct, R> onBook, Function<FashionProduct, R> onFashion) { public R visit(Product product) { return switch (product) { case ElectronicProduct e -> onElectronic.apply(e); case BookProduct b -> onBook.apply(b); case FashionProduct f -> onFashion.apply(f); }; } } Which makes an operation a value you can assemble on the fly: Java ProductVisitor<BigDecimal> vat = new ProductVisitor<>( e -> ..., b -> ..., f -> ...); BigDecimal amount = vat.visit(book); The Builder This design pattern belongs to the creational category, like the factory, but it solves a different problem. The factory hides which concrete type gets instantiated, while the Builder assembles a single, complex object step by step, separating its construction from its representation. It is the classic answer to the telescoping-constructor problem: an object with many parameters, among which some are required, most optional, whose constructor would otherwise explode into a combinatorial set of overloads. Our Product records have only three required fields, so they don't motivate a builder. We therefore introduce an Order: a customer order that aggregates the common products as line items and adds several optional attributes: a coupon code, a gift-wrap flag, and a free-text note. Whatever the style, the target is the same immutable value: Java public record Order( String customer, String currency, List<Product> items, Optional<String> coupon, boolean giftWrapped, Optional<String> note) { public Order { Objects.requireNonNull(customer, "Customer is null"); Objects.requireNonNull(currency, "Currency is null"); items = items == null ? List.of() : List.copyOf(items); coupon = coupon == null ? Optional.empty() : coupon; note = note == null ? Optional.empty() : note; } public BigDecimal subtotal() { ... } } The Object-Oriented Approach The figure below shows the class diagram of the object-oriented builder: The classic Gang of Four Builder is a mutable accumulator. The required arguments are captured up front; the optional ones are added through fluent calls that all return this, and build() freezes the accumulated state into the immutable Order: Java public final class OrderBuilder { private final String customer; private final String currency; private final List<Product> items = new ArrayList<>(); private String coupon; private boolean giftWrapped; private String note; public static OrderBuilder of(String customer, String currency) { ... } public OrderBuilder addItem(Product item) { items.add(item); return this; } public OrderBuilder coupon(String coupon) { this.coupon = coupon; return this; } public OrderBuilder giftWrap() { this.giftWrapped = true; return this; } public OrderBuilder note(String note) { this.note = note; return this; } public Order build() { return new Order(customer, currency, items, Optional.ofNullable(coupon), giftWrapped, Optional.ofNullable(note)); } } Building an order reads as a sentence, and you only mention the parts you actually need: Java Order order = OrderBuilder.of("Alice", "EUR") .addItem(book).addItem(phone) .coupon("SUMMER").giftWrap() .build(); The Functional Approach Look now at the class diagram of the functional style implementation: The functional counterpart keeps the same immutable Order target but drops the mutable accumulator. Each build step becomes a first-class UnaryOperator<Order> value, a pure function mapping one immutable Order to the next by returning a modified copy: Java public static UnaryOperator<Order> addItem(Product item) { return order -> new Order(order.customer(), order.currency(), Stream.concat(order.items().stream(), Stream.of(item)).toList(), order.coupon(), order.giftWrapped(), order.note()); } Because the steps are ordinary values, they are not called on a builder, but they are composed with andThen, exactly as the factory composed its factoryfunction and the visitor composed its operations: Java Function<Order, Order> config = addItem(book) .andThen(addItem(phone)) .andThen(coupon("SUMMER")) .andThen(giftWrap()); Order order = config.apply(OrderBuilder.empty("Alice", "EUR")); This is more than a stylistic variation. In the OOP version, a step is a method call that exists only for the duration of the chain. In the FP version, a step is a value that can be stored in a variable, passed to another method, kept in a list of steps and applied later, or reused the very same step twice: Java UnaryOperator<Order> addBook = addItem(book); Order order = addBook.andThen(addBook).apply(OrderBuilder.empty("Alice", "EUR")); The object-oriented Builder wraps a stateful object around the immutable target, while the functional one expresses construction as the composition of pure copy functions over it. "Turtles all the way down", and both land on the same Order. The Decorator This design pattern belongs to the structural category, and its purpose is to attach additional responsibilities to an object dynamically by wrapping it in another object that shares the same interface. It is the flexible alternative to subclassing for extending behavior: rather than a combinatorial explosion of DiscountedTaxedGiftWrappedProduct subclasses, you wrap a product in as many independent decorators as you need, and they stack. We reuse the same Product domain. Each decorator changes the price() and the description() while leaving everything else untouched. To keep the pattern visibly distinct from the visitor, whose rules varied per product type, the decorators here apply the same rule to every product: Discounted: 10% off the wrapped price.Taxed: adds 20% VAT to the wrapped price.GiftWrapped: adds a flat `5.00` wrapping fee. Because they stack, a 100.00 book decorated Discounted → Taxed→GiftWrapped goes 100.00 → 90.00 → 108.00 → 113.00, and its description reads "A book discounted, VAT incl., gift-wrapped." The Object-Oriented Approach The figure below shows the class diagram of the object-oriented decorator: The classic Gang of Four Decorator is an object that implements the component interface and holds a reference to another component, delegating the untouched operations and overriding the ones it enhances. An abstract ProductDecorator captures the delegation once: Java public abstract class ProductDecorator implements Product { protected final Product product; protected ProductDecorator(Product product) { this.product = Objects.requireNonNull(product, "Product is null"); } public String name() { return product.name(); } public String description() { return product.description(); } public BigDecimal price() { return product.price(); } public ProductType type() { return product.type(); } } Each concrete decorator then overrides only what it changes: Java public class Discounted extends ProductDecorator { private static final BigDecimal RATE = new BigDecimal("0.10"); public Discounted(Product product) { super(product); } public BigDecimal price() { return product.price().subtract(amount(product.price(), RATE)); } public String description() { return product.description() + " (discounted)"; } } Since a decorator is a Product, decorators wrap decorators, and the enhancements compose by nesting: Java Product wrapped = new GiftWrapped(new Taxed(new Discounted(new BaseProduct(book)))); BigDecimal price = wrapped.price(); // 113.00 The leaf being wrapped is a BaseProduct, a small record that adapts a shared common.Product into the decorator's own interface. This is necessary because common.Product is sealed and so, exactly like the object-oriented visitor, the decorator cannot make the common records implement its interface directly. The Functional Approach Look now at the class diagram of the functional style implementation: The functional counterpart of a decorator is simply a function which maps a product to an enhanced product and implemented as an UnaryOperator<Product>. Because the common records are immutable, "enhancing" one means rebuilding it through the ProductType factory, already seen at the very beginning, which is why the FP side reuses common directly with no adapter: Java public static final UnaryOperator<Product> DISCOUNTED = product -> product.type().newInstance(product.name(), product.description() + " (discounted)", product.price().subtract(amount(product.price(), "0.10"))); Being ordinary values, the decorations compose with andThen, exactly as the factory composed its factory function, the visitor composed its operations, and the builder composed its steps: Java UnaryOperator<Product> decorate = DISCOUNTED.andThen(TAXED).andThen(GIFT_WRAPPED); Product wrapped = decorate.apply(book); // price 113.00 And, just like the functional builder step, a decoration is a reusable first-class value. For example, the same discount could be applied twice: Java Product wrapped = DISCOUNTED.andThen(DISCOUNTED).apply(book); // 100 -> 90 -> 81 The object-oriented Decorator wraps the component in a stack of objects sharing its interface, while the functional one expresses the very same stacking as the composition of pure Product to Product functions. "Turtles all the way down", and both land on the same enhanced product. The Strategy This design pattern belongs to the behavioral category, and its purpose is to define a family of algorithms, encapsulate each one of them, and make them interchangeable, such that the algorithm may vary independently of the client using it. Where the decorator asked what else should happen to this object ?, the strategy asks which one of these algorithms should be applied ?. We keep the same Product domain and we compute a shipping cost for it. Three interchangeable algorithms are provided: Standard: a flat 4.99 fee.Express: 9.99 plus 2% of the product price.FreeOver: the familiar "free delivery over 50.00" commercial rule. It is parameterized by a price threshold and by the strategy to apply when the threshold isn't reached: should the product price be greater than or equal to the threshold, the shipping is free; otherwise, the product doesn't qualify, and the cost is the one computed by that other strategy. For our 100.00 book, the standard shipping costs 4.99 and the express one costs 11.99. As for the free-over one, with a threshold of 50.00 and a StandardShipping()strategy, the cost is 0.00, since 100.00 is above the threshold. Raising that same threshold to 150.00 falls back to the standard shipping and, hence, the cost is 4.99. Notice that, unlike the visitor, nothing here varies per product type: what varies is the algorithm, and it is the caller that picks it. The Object-Oriented Approach The figure below shows the class diagram of the object-oriented strategy: The classic Gang of Four Strategy declares an interface for the family of algorithms and one class per algorithm: Java public interface ShippingStrategy { BigDecimal cost(Product product); } public class ExpressShipping implements ShippingStrategy { private static final BigDecimal FEE = new BigDecimal("9.99"); private static final BigDecimal RATE = new BigDecimal("0.02"); public BigDecimal cost(Product product) { return FEE.add(product.price().multiply(RATE).setScale(2, RoundingMode.HALF_UP)); } } StandardShipping and ExpressShipping are stateless, their fees being constants. But an algorithm that needs to be parameterized has nowhere to keep its parameters other than instance fields and, hence, becomes a class with state. This is the case of FreeOverShipping, which holds both its threshold and the strategy to fall back to below it, every such pair defining a different algorithm: Java public class FreeOverShipping implements ShippingStrategy { private final BigDecimal threshold; private final ShippingStrategy otherwise; public FreeOverShipping(BigDecimal threshold, ShippingStrategy otherwise) { ... } public BigDecimal cost(Product product) { return product.price().compareTo(threshold) >= 0 ? FREE : otherwise.cost(product); } } Last but not least, the context is the object that uses the algorithm without knowing which one it is. It only holds a reference to the interface, which is what allows the algorithm to be replaced at runtime: Java ShippingCalculator calculator = new ShippingCalculator(new StandardShipping()); BigDecimal cost = calculator.cost(book); // 4.99 BigDecimal total = calculator.total(book); // 104.99 calculator.setStrategy(new ExpressShipping()); cost = calculator.cost(book); // 11.99 total = calculator.total(book); // 111.99 Contrary to the visitor and to the decorator, the strategy doesn't require anything at all from the elements it processes: no `accept` method and no shared component interface. Consequently, and this is the first time it happens on the object-oriented side, the module reuses the sealed common.Product directly, with neither its own hierarchy, nor any adapter. The Functional Approach Look now at the class diagram of the functional style implementation: Of all the patterns seen so far, this is the one where the functional answer is the most radical. The interface ShippingStrategy in the OO implementation declares one single method and holds no state, such that everything it tells us is a Product comes in, a BigDecimal comes out. In functional terms, it is nothing more than a Function<Product, BigDecimal> type. So each algorithm becomes a plain value of the function type, for example: Java public static final Function<Product, BigDecimal> EXPRESS = product -> EXPRESS_FEE.add(product.price().multiply(EXPRESS_RATE).setScale(2, RoundingMode.HALF_UP)); As opposed to the OO side, which required the FreeOverShipping class holding the threshold and the shipping strategy, the FP side captures them in a closure. So this class on the OO side becomes on the FP side a higher-order function, i.e. a function returning the strategy itself: Java public static Function<Product, BigDecimal> freeOver(BigDecimal threshold, Function<Product, BigDecimal> otherwise) { return product -> product.price().compareTo(threshold) >= 0 ? FREE : otherwise.apply(product); } The very same happens to ShippingCalculator, the context class on the OOP side. Its whole reason to exist was to hold a strategy in a field, such that its cost()and total() operations could delegate to it. But a context is just an operation parameterized by an algorithm and this, once again, is precisely a higher-order function. Hence, the ShippingCalculator.total() method becomes: Java public static Function<Product, BigDecimal> totalWith(Function<Product, BigDecimal> strategy) { return product -> product.price().add(strategy.apply(product)); } such that the following call on the OO side: Java ShippingCalculator calculator = new ShippingCalculator(new StandardShipping()); ... BigDecimal total = calculator.total(book); becomes on the FP side: Java BigDecimal total = totalWith(STANDARD).apply(book); There is no field to hold the strategy anymore and, consequently, no setStrategy()method either. Here the strategy is an argument which doesn't need to be stored in the context, just call the function with the right value. But the real advantage of the strategies as ordinary values is that they can be combined. Picking the cheapest of several shipping options requires yet another class on the OO side, while here it's a simple combinator: Java Function<Product, BigDecimal> best = cheapest(STANDARD, EXPRESS); // 4.99 And as usual, they compose with andThen, for example to apply a promotion to whatever cost has been computed: Java Function<Product, BigDecimal> promo = EXPRESS.andThen(cost -> cost.divide(TWO, 2, RoundingMode.HALF_UP)); // 6.00 The OO Strategy encapsulates each algorithm in a class implementing a common interface and injects the chosen one into a context object, while the functional one observes that such an interface describes nothing but a function type which the JDK already provides and, consequently, keeps only the algorithms themselves. "Turtles all the way down", and both compute the same cost. Project Structure The code is organized as a multi-module Maven project. The product domain lives in its own common module: a sealed Product interface, the three product records, and the ProductType enumerated which already carries the FP factory function seen above. Everything that can reuse that domain does: Plain Text oop-fp-design-patterns (parent POM) ├── common sealed Product, the records, ProductType(+factory) ├── factory (→ common) ProductFactory (OOP); the FP factory *is* common.ProductType ├── visitor (→ common) FP: operations over the common records (switch + lambda bundle) │ OOP: its own element hierarchy (see below) ├── builder (→ common) immutable Order over the common records; OOP: fluent │ OrderBuilder; FP: composed UnaryOperator<Order> steps ├── decorator (→ common) FP: composed UnaryOperator<Product> decorations over the │ common records; OOP: its own Product interface (see below) └── strategy (→ common) shipping algorithms over the common records; OOP: the ShippingStrategy hierarchy + context; FP: plain Function<Product, BigDecimal> values The FP factory, the FP visitor and the FP decorator all operate directly on the common records, so nothing is duplicated there, and the Strategy does so on both of its sides. The two exceptions are the object-oriented Visitor and the object-oriented Decorator. The Visitor needs an accept method on every element (double dispatch). The Decorator needs a non-sealed Product interface that its wrappers can implement. In both cases, common.Product is sealed and cannot be extended from another module, so each owns its own element/component types and reuses only the ProductType enumerated. The OOP decorator bridges back to common through a small BaseProduct adapter. This asymmetry is not accidental. The classic Visitor requires every element to expose an accept method, and the classic Decorator requires every component to share the wrappers' interface. Both couple the elements to the pattern's abstraction, so they cannot be the sealed records defined in common. The functional approach has no such coupling: it operates over the sealed type from the outside, pattern-matching for the visitor, rebuilding through the factory for the decorator, so the elements know nothing about the operations applied to them and, hence, can be the shared common records. The Strategy confirms the rule the other way around: it doesn't couple the elements to its abstraction either, only the client to it, and this is precisely why it is the only pattern here whose object-oriented implementation reuses `common` as freely as its functional one. The full code of these examples, including the associated unit tests, can be found here. Have a great summer, everyone!
Software Engineer,
IBM