You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2022/09/09 20:28:27 UTC

[pulsar-client-reactive] 01/01: Initial commit

This is an automated email from the ASF dual-hosted git repository.

lhotari pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pulsar-client-reactive.git

commit 6067b9e10cb951bde635d73a2e3ffc87187c4539
Author: Lari Hotari <lh...@apache.org>
AuthorDate: Fri Sep 9 23:20:13 2022 +0300

    Initial commit
---
 .asf.yaml                                          |  50 ++
 .gitattributes                                     |   6 +
 .github/workflows/ci.yml                           |  24 +
 .gitignore                                         |   8 +
 CONTRIBUTING.adoc                                  |  41 ++
 LICENSE                                            | 201 ++++++++
 README.adoc                                        | 228 +++++++++
 build.gradle                                       |  24 +
 buildSrc/build.gradle                              |  33 ++
 buildSrc/settings.gradle                           |  23 +
 ...ar-client-reactive.codestyle-conventions.gradle |  38 ++
 ...nt-reactive.integration-test-conventions.gradle |  38 ++
 ...lsar-client-reactive.library-conventions.gradle |  94 ++++
 checkstyle/HEADER.txt                              |  13 +
 checkstyle/checkstyle.xml                          |   9 +
 gradle.properties                                  |   1 +
 gradle/libs.versions.toml                          |  38 ++
 gradle/wrapper/gradle-wrapper.jar                  | Bin 0 -> 60756 bytes
 gradle/wrapper/gradle-wrapper.properties           |   5 +
 gradlew                                            | 240 +++++++++
 gradlew.bat                                        |  91 ++++
 idea/codeStyleConfig.xml                           | 128 +++++
 pulsar-client-reactive-adapter/build.gradle        |  26 +
 .../adapter/ReactiveMessageConsumerE2ETest.java    |  70 +++
 .../adapter/ReactiveMessagePipelineE2ETest.java    | 189 +++++++
 .../adapter/ReactiveMessageReaderE2ETest.java      |  59 +++
 .../adapter/ReactiveMessageSenderE2ETest.java      |  99 ++++
 .../client/adapter/SingletonPulsarContainer.java   |  43 ++
 .../src/intTest/resources/log4j2-test.xml          |  31 ++
 .../AdaptedReactivePulsarClientFactory.java        |  60 +++
 .../adapter/DefaultMessageGroupingFunction.java    |  48 ++
 .../client/adapter/ProducerCacheProvider.java      |  26 +
 .../adapter/ProducerCacheProviderFactory.java      |  23 +
 .../adapter/AdaptedReactiveMessageConsumer.java    | 228 +++++++++
 .../AdaptedReactiveMessageConsumerBuilder.java     |  56 +++
 .../adapter/AdaptedReactiveMessageReader.java      | 146 ++++++
 .../AdaptedReactiveMessageReaderBuilder.java       |  83 +++
 .../adapter/AdaptedReactiveMessageSender.java      | 202 ++++++++
 .../AdaptedReactiveMessageSenderBuilder.java       | 101 ++++
 .../adapter/AdapterImplementationFactory.java      |  67 +++
 .../ConcurrentHashMapProducerCacheProvider.java    |  57 +++
 .../client/internal/adapter/ProducerCache.java     |  90 ++++
 .../internal/adapter/ProducerCacheEntry.java       | 140 ++++++
 .../client/internal/adapter/ProducerCacheKey.java  |  63 +++
 .../internal/adapter/PulsarFutureAdapter.java      | 101 ++++
 .../internal/adapter/ReactiveConsumerAdapter.java  |  61 +++
 .../adapter/ReactiveConsumerAdapterFactory.java    |  37 ++
 .../internal/adapter/ReactiveProducerAdapter.java  | 123 +++++
 .../adapter/ReactiveProducerAdapterFactory.java    |  40 ++
 .../adapter/ReactivePulsarResourceAdapter.java     |  65 +++
 .../ReactivePulsarResourceAdapterPulsarClient.java |  48 ++
 .../internal/adapter/ReactiveReaderAdapter.java    |  57 +++
 .../adapter/ReactiveReaderAdapterFactory.java      |  37 ++
 ...sar.reactive.client.api.MessageGroupingFunction |   1 +
 .../internal/adapter/InflightLimiterTest.java      |  67 +++
 .../src/test/resources/log4j2-test.xml             |  31 ++
 pulsar-client-reactive-api/build.gradle            |  14 +
 .../reactive/client/api/EndOfStreamAction.java     |  26 +
 .../api/ImmutableReactiveMessageConsumerSpec.java  | 299 +++++++++++
 .../api/ImmutableReactiveMessageReaderSpec.java    | 105 ++++
 .../api/ImmutableReactiveMessageSenderSpec.java    | 252 ++++++++++
 .../reactive/client/api/InstantStartAtSpec.java    |  56 +++
 .../client/api/MessageGroupingFunction.java        |  34 ++
 .../reactive/client/api/MessageIdStartAtSpec.java  |  64 +++
 .../pulsar/reactive/client/api/MessageResult.java  |  51 ++
 .../pulsar/reactive/client/api/MessageSpec.java    |  31 ++
 .../reactive/client/api/MessageSpecBuilder.java    | 152 ++++++
 .../api/MutableReactiveMessageConsumerSpec.java    | 554 +++++++++++++++++++++
 .../api/MutableReactiveMessageReaderSpec.java      | 174 +++++++
 .../api/MutableReactiveMessageSenderSpec.java      | 452 +++++++++++++++++
 .../client/api/ReactiveMessageConsumer.java        |  39 ++
 .../client/api/ReactiveMessageConsumerBuilder.java | 248 +++++++++
 .../client/api/ReactiveMessageConsumerSpec.java    |  99 ++++
 .../client/api/ReactiveMessagePipeline.java        |  27 +
 .../client/api/ReactiveMessagePipelineBuilder.java |  63 +++
 .../reactive/client/api/ReactiveMessageReader.java |  29 ++
 .../client/api/ReactiveMessageReaderBuilder.java   | 101 ++++
 .../client/api/ReactiveMessageReaderSpec.java      |  45 ++
 .../reactive/client/api/ReactiveMessageSender.java |  29 ++
 .../client/api/ReactiveMessageSenderBuilder.java   | 188 +++++++
 .../client/api/ReactiveMessageSenderCache.java     |  27 +
 .../client/api/ReactiveMessageSenderSpec.java      |  86 ++++
 .../reactive/client/api/ReactivePulsarClient.java  |  68 +++
 .../pulsar/reactive/client/api/StartAtSpec.java    |  58 +++
 .../internal/api/ApiImplementationFactory.java     |  61 +++
 .../client/internal/api/DefaultMessageResult.java  |  51 ++
 .../client/internal/api/DefaultMessageSpec.java    | 103 ++++
 .../internal/api/DefaultMessageSpecBuilder.java    | 140 ++++++
 .../api/DefaultReactiveMessagePipeline.java        | 199 ++++++++
 .../api/DefaultReactiveMessagePipelineBuilder.java | 160 ++++++
 .../client/internal/api/EmptyMessageResult.java    |  48 ++
 .../api/GroupOrderedMessageProcessors.java         | 100 ++++
 .../client/internal/api/InflightLimiter.java       | 294 +++++++++++
 .../client/internal/api/InternalMessageSpec.java   |  26 +
 .../client/internal/api/PublisherTransformer.java  |  39 ++
 .../client/internal/api/ValueOnlyMessageSpec.java  |  34 ++
 .../build.gradle                                   |  16 +
 .../CaffeineProducerCacheProvider.java             |  71 +++
 .../CaffeineProducerCacheProviderFactory.java      |  29 ++
 ...ive.client.adapter.ProducerCacheProviderFactory |   1 +
 settings.gradle                                    |  21 +
 101 files changed, 8672 insertions(+)

diff --git a/.asf.yaml b/.asf.yaml
new file mode 100644
index 0000000..c30d756
--- /dev/null
+++ b/.asf.yaml
@@ -0,0 +1,50 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+github:
+  description: "Reactive client for Apache Pulsar"
+  homepage: https://pulsar.apache.org/
+  labels:
+    - pulsar
+    - apache-pulsar
+    - reactive-streams
+    - project-reactor
+    - backpressure
+  features:
+    # Enable wiki for documentation
+    wiki: true
+    # Enable issues management
+    issues: true
+    # Enable projects for project management boards
+    projects: true
+  enabled_merge_buttons:
+    # enable squash button:
+    squash:  true
+    # disable merge button:
+    merge:   false
+    # disable rebase button:
+    rebase:  false
+  protected_branches:
+    main: {}
+
+notifications:
+  commits:      commits@pulsar.apache.org
+  issues:       commits@pulsar.apache.org
+  pullrequests: commits@pulsar.apache.org
+  discussions:  dev@pulsar.apache.org
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..00a51af
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# These are explicitly windows files and should use crlf
+*.bat           text eol=crlf
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f476193
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI build
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-20.04
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Set up JDK 11
+        uses: actions/setup-java@v2
+        with:
+          distribution: 'temurin'
+          java-version: 11
+
+      - name: Check with Gradle
+        run: ./gradlew check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6bdd94f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Ignore Gradle project-specific cache directory
+.gradle
+
+# Ignore Gradle build output directory
+build
+.idea
+.project
+.settings
diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc
new file mode 100755
index 0000000..3d6fb8a
--- /dev/null
+++ b/CONTRIBUTING.adoc
@@ -0,0 +1,41 @@
+= Contributing to Reactive client for Apache Pulsar
+
+:github: https://github.com/apache/pulsar-client-reactive
+
+Reactive client for Apache Pulsar is released under the Apache 2.0 license. If you would like to contribute something, or want to hack on the code this document should help you get started.
+
+
+== Using GitHub Issues
+We use GitHub issues to track bugs and enhancements.
+
+If you are reporting a bug, please help to speed up problem diagnosis by providing as much information as possible.
+Ideally, that would include a small sample project that reproduces the problem.
+
+== Reporting Security Vulnerabilities
+If you think you have found a security vulnerability in Apache Pulsar or Reactive client for Apache Pulsar please *DO NOT* disclose it publicly until we've had a chance to fix it.
+Please don't report security vulnerabilities using GitHub issues, instead head over to https://github.com/apache/pulsar/security/policy and learn how to disclose them responsibly.
+
+
+== Code Conventions and Housekeeping
+None of these is essential for a pull request, but they will all help.  They can also be
+added after the original pull request but before a merge.
+
+* We use the https://github.com/spring-io/spring-javaformat/[Spring JavaFormat] project to apply code formatting conventions.
+  If you use Eclipse and you follow the '`Importing into eclipse`' instructions below you should get project specific formatting automatically.
+  You can also install the https://github.com/spring-io/spring-javaformat/#intellij-idea[Spring JavaFormat IntelliJ Plugin] or format the code from the Gradle build by running `./gradlew format`.
+// NYI: Note that if you have format violations in `buildSrc`, you can fix them by running `./gradlew -p buildSrc format` from the project root directory.
+* The build includes Checkstyle rules for many of our code conventions. Run `./gradlew checkstyleMain checkstyleTest` if you want to check your changes are compliant.
+* Make sure all new `.java` files have a Javadoc class comment with at least an `@author` tag identifying you, and preferably at least a paragraph on what the class is for.
+* Add the ASF license header comment to all new `.java` files (copy from existing files in the project).
+* Add yourself as an `@author` to the `.java` files that you modify substantially (more than cosmetic changes).
+* Add some Javadocs.
+* A few unit tests would help a lot as well -- someone has to do it.
+* Verification tasks, including tests and Checkstyle, can be executed by running `./gradlew check` from the project root.
+
+* If no-one else is using your branch, please rebase it against the current main branch (or other target branch in the project).
+* When writing a commit message please follow https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions].
+
+
+
+== Working with the Code
+For information on editing, building, and testing the code, see the link:${github}/wiki/Working-with-the-Code[Working with the Code] page on the project wiki.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/README.adoc b/README.adoc
new file mode 100644
index 0000000..127bc0c
--- /dev/null
+++ b/README.adoc
@@ -0,0 +1,228 @@
+= Reactive client for Apache Pulsar
+
+:github: https://github.com/apache/pulsar-client-reactive
+
+Reactive client for Apache Pulsar which is compatible with the Reactive Streams specification.
+This uses Project Reactor as the Reactive Streams implementation.
+
+== Getting it
+
+There is no release available yet. Snapshot builds are published to Apache snapshot repository, https://repository.apache.org/content/repositories/snapshots.
+
+*This library requires Java 8 or + to run*.
+
+With Gradle:
+
+[source,groovy]
+----
+repositories {
+    maven {
+        url 'https://repository.apache.org/content/repositories/snapshots'
+	}
+}
+
+dependencies {
+    implementation "org.apache.pulsar:pulsar-client-reactive-adapter:0.1.0-SNAPSHOT"
+}
+----
+
+With Maven:
+
+[source,xml]
+----
+<repository>
+	<id>apache.snapshots</id>
+	<name>Apache Development Snapshot Repository</name>
+	<url>https://repository.apache.org/content/repositories/snapshots/</url>
+	<releases>
+		<enabled>false</enabled>
+	</releases>
+	<snapshots>
+		<enabled>true</enabled>
+	</snapshots>
+</repository>
+
+<dependencies>
+    <dependency>
+        <groupId>org.apache.pulsar</groupId>
+        <artifactId>reactive-pulsar-adapter</artifactId>
+        <version>0.1.0-SNAPSHOT</version>
+    </dependency>
+</dependencies>
+----
+
+== Usage
+
+=== Initializing the library
+
+==== In standalone application
+
+Using an existing PulsarClient instance:
+
+[source,java]
+----
+ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+----
+
+=== Sending messages
+
+[source,java]
+----
+ReactiveMessageSender<String> messageSender = reactivePulsarClient
+        .messageSender(Schema.STRING)
+        .topic(topicName)
+        .maxInflight(100)
+        .build();
+Mono<MessageId> messageId = messageSender
+        .sendMessage(Mono.just(MessageSpec.of("Hello world!")));
+// for demonstration
+messageId.subscribe(System.out::println);
+----
+
+=== Sending messages with cached producer
+
+By default a ConcurrentHashMap based cache is used. It's recommended to use a more advanced cache based on Caffeine. The cache will get used as the default implementation when it is on the classpath.
+
+Adding Caffeine based producer cache with Gradle:
+
+[source,groovy]
+----
+dependencies {
+    implementation "org.apache.pulsar:pulsar-client-reactive-adapter:0.1.0-SNAPSHOT"
+    implementation "org.apache.pulsar:pulsar-client-reactive-producer-cache-caffeine:0.1.0-SNAPSHOT"
+}
+----
+
+Adding Caffeine based producer cache with Maven:
+
+[source,xml]
+----
+<dependencies>
+    <dependency>
+        <groupId>org.apache.pulsar</groupId>
+        <artifactId>reactive-pulsar-adapter</artifactId>
+        <version>0.1.0-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+        <groupId>org.apache.pulsar</groupId>
+        <artifactId>reactive-pulsar-producer-cache-caffeine</artifactId>
+        <version>0.1.0-SNAPSHOT</version>
+    </dependency>
+</dependencies>
+----
+
+Usage example of cache
+
+[source,java]
+----
+ReactiveMessageSender<String> messageSender = reactivePulsarClient
+        .messageSender(Schema.STRING)
+        .cache(AdaptedReactivePulsarClientFactory.createCache())
+        .topic(topicName)
+        .maxInflight(100)
+        .build();
+Mono<MessageId> messageId = messageSender
+        .sendMessage(Mono.just(MessageSpec.of("Hello world!")));
+// for demonstration
+messageId.subscribe(System.out::println);
+----
+
+It is recommended to use a cached producer in most cases. The cache enables reusing the Pulsar Producer instance and related resources across multiple message sending calls.
+This improves performance since a producer won't have to be created and closed before and after sending a message.
+
+The adapter library implementation together with the cache implementation will also enable reactive backpressure for sending messages. The `maxInflight` setting will limit the number of messages that are pending from the client to the broker. The solution will limit reactive streams subscription requests to keep the number of pending messages under the defined limit. This limit is per-topic and impacts the local JVM only.
+
+=== Reading messages
+
+Reading all messages for a topic:
+
+[source,java]
+----
+    ReactiveMessageReader<String> messageReader =
+            reactivePulsarClient.messageReader(Schema.STRING)
+                    .topic(topicName)
+                    .build();
+    messageReader.readMessages()
+            .map(Message::getValue)
+            // for demonstration
+            .subscribe(System.out::println);
+----
+
+By default, the stream will complete when the tail of the topic is reached.
+
+==== Example: poll for up to 5 new messages and stop polling when a timeout occurs
+
+With `.endOfStreamAction(EndOfStreamAction.POLL)` the Reader will poll for new messages when the reader reaches the end of the topic.
+
+[source,java]
+----
+    ReactiveMessageReader<String> messageReader =
+            reactivePulsarClient.messageReader(Schema.STRING)
+                    .topic(topicName)
+                    .startAtSpec(StartAtSpec.LATEST)
+                    .endOfStreamAction(EndOfStreamAction.POLL)
+                    .build();
+    messageReader.readMessages()
+            .take(Duration.ofSeconds(5))
+            .take(5)
+            // for demonstration
+            .subscribe(System.out::println);
+----
+
+=== Consuming messages
+
+[source,java]
+----
+    ReactiveMessageConsumer<String> messageConsumer=
+        reactivePulsarClient.messageConsumer(Schema.STRING)
+        .topic(topicName)
+        .subscriptionName("sub")
+        .build();
+    messageConsumer.consumeMessages(messageFlux ->
+                    messageFlux.map(message ->
+                            MessageResult.acknowledge(message.getMessageId(), message.getValue())))
+        .take(Duration.ofSeconds(2))
+        // for demonstration
+        .subscribe(System.out::println);
+----
+
+=== Consuming messages using a message handler component with auto-acknowledgements
+
+[source,java]
+----
+ReactiveMessageHandler reactiveMessagePipeline=
+	reactivePulsarClient
+        .messagePipeline(reactivePulsarClient
+           .messageConsumer(Schema.STRING)
+           .subscriptionName("sub")
+           .topic(topicName)
+           .build())
+        .messageHandler(message -> Mono.fromRunnable(()->{
+            System.out.println(message.getValue());
+        }))
+        .build()
+        .start();
+// for demonstration
+// the reactive message handler is running in the background, delay for 10 seconds
+Thread.sleep(10000L);
+// now stop the message handler component
+reactiveMessagePipeline.stop();
+----
+
+== License
+
+Reactive client for Apache Pulsar is Open Source Software released under the link:www.apache.org/licenses/LICENSE-2.0[Apache Software License 2.0].
+
+== How to Contribute
+
+The library is Apache 2.0 licensed.
+
+Contributions are welcome. Please discuss larger changes on the link:mailto:dev@pulsar.apache.org[Apache Pulsar dev mailing list]. There's a link:CONTRIBUTING.adoc[contributing guide] with more details.
+
+== Bugs and Feature Requests
+
+If you detect a bug or have a feature request or a good idea for Reactive client for Apache Pulsar, please link:${github}/issues/new[open a GitHub issue].
+
+== Questions
+
+Please use https://stackoverflow.com/tags/reactive-pulsar[[reactive-pulsar\]] tag on Stackoverflow. https://stackoverflow.com/questions/ask?tags=apache-pulsar,reactive-pulsar[Ask a question now].
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..89dc173
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+    alias(libs.plugins.versions)
+}
+
+allprojects {
+    group = 'org.apache.pulsar.reactive.client'
+    apply plugin: 'io.spring.javaformat'
+}
diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle
new file mode 100644
index 0000000..64f3763
--- /dev/null
+++ b/buildSrc/build.gradle
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.time.Year
+
+plugins {
+	id 'groovy-gradle-plugin'
+}
+
+repositories {
+	mavenCentral()
+	maven {
+		url "https://plugins.gradle.org/m2/"
+	}
+}
+
+dependencies {
+	implementation libs.spring.javaformat.gradle.plugin
+	implementation libs.licenser
+}
\ No newline at end of file
diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle
new file mode 100644
index 0000000..5eb49f7
--- /dev/null
+++ b/buildSrc/settings.gradle
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+dependencyResolutionManagement {
+	versionCatalogs {
+		libs {
+			from(files("../gradle/libs.versions.toml"))
+		}
+	}
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/pulsar-client-reactive.codestyle-conventions.gradle b/buildSrc/src/main/groovy/pulsar-client-reactive.codestyle-conventions.gradle
new file mode 100644
index 0000000..fee209b
--- /dev/null
+++ b/buildSrc/src/main/groovy/pulsar-client-reactive.codestyle-conventions.gradle
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.time.Year
+
+plugins {
+	id 'checkstyle'
+	id "org.cadixdev.licenser"
+}
+
+dependencies {
+	checkstyle libs.spring.javaformat.checkstyle
+}
+
+checkstyle {
+	toolVersion libs.versions.checkstyle.get()
+	configFile project.rootProject.file('checkstyle/checkstyle.xml')
+}
+
+license {
+	header = rootProject.file('checkstyle/HEADER.txt')
+	ext {
+		year = Year.now().getValue().toString()
+	}
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/pulsar-client-reactive.integration-test-conventions.gradle b/buildSrc/src/main/groovy/pulsar-client-reactive.integration-test-conventions.gradle
new file mode 100644
index 0000000..bc3d28f
--- /dev/null
+++ b/buildSrc/src/main/groovy/pulsar-client-reactive.integration-test-conventions.gradle
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+sourceSets {
+	intTest {
+		compileClasspath += sourceSets.main.output
+		runtimeClasspath += sourceSets.main.output
+	}
+}
+
+configurations {
+	intTestImplementation.extendsFrom implementation
+	intTestRuntimeOnly.extendsFrom runtimeOnly
+}
+
+tasks.register('integrationTest', Test) {
+	description = 'Runs integration tests.'
+	group = 'verification'
+
+	testClassesDirs = sourceSets.intTest.output.classesDirs
+	classpath = sourceSets.intTest.runtimeClasspath
+	shouldRunAfter test
+}
+
+check.dependsOn integrationTest
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/pulsar-client-reactive.library-conventions.gradle b/buildSrc/src/main/groovy/pulsar-client-reactive.library-conventions.gradle
new file mode 100644
index 0000000..e6a817d
--- /dev/null
+++ b/buildSrc/src/main/groovy/pulsar-client-reactive.library-conventions.gradle
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+	id 'java-library'
+	id 'maven-publish'
+	id 'signing'
+}
+
+repositories {
+	mavenCentral()
+}
+
+java {
+	withJavadocJar()
+	withSourcesJar()
+}
+
+sourceCompatibility = '8'
+
+publishing {
+	repositories {
+		maven {
+			if (project.hasProperty("publishDebug")) {
+				url = project.property("publishDebug")
+			} else {
+				name = 'asf'
+				url = version.endsWith('-SNAPSHOT') ? 'https://repository.apache.org/snapshots' : 'https://repository.apache.org/service/local/staging/deploy/maven2'
+				credentials(PasswordCredentials)
+			}
+		}
+	}
+	publications {
+		mavenJava(MavenPublication) { publication ->
+			from components.java
+			versionMapping {
+				usage('java-api') {
+					fromResolutionOf('runtimeClasspath')
+				}
+				usage('java-runtime') {
+					fromResolutionResult()
+				}
+			}
+			pom {
+				afterEvaluate {
+					name = project.name
+					description = project.description
+				}
+				url = 'https://github.com/apache/pulsar-client-reactive'
+				licenses {
+					license {
+						name = 'The Apache License, Version 2.0'
+						url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
+						distribution = 'repo'
+					}
+				}
+				developers {
+					developer {
+						id = 'lhotari'
+						name = 'Lari Hotari'
+						email = 'lhotari@apache.org'
+					}
+				}
+				scm {
+					connection = 'scm:git:https://github.com/apache/pulsar-client-reactive.git'
+					developerConnection = 'scm:git:https://github.com/apache/pulsar-client-reactive.git'
+					url = 'https://github.com/apache/pulsar-client-reactive'
+				}
+			}
+		}
+	}
+}
+
+tasks.withType(Test) {
+	useJUnitPlatform()
+}
+
+signing {
+	required !project.hasProperty("publishDebug")
+	sign publishing.publications.mavenJava
+}
diff --git a/checkstyle/HEADER.txt b/checkstyle/HEADER.txt
new file mode 100644
index 0000000..37fc63b
--- /dev/null
+++ b/checkstyle/HEADER.txt
@@ -0,0 +1,13 @@
+Copyright ${year} the original author or authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
\ No newline at end of file
diff --git a/checkstyle/checkstyle.xml b/checkstyle/checkstyle.xml
new file mode 100644
index 0000000..1ad50d8
--- /dev/null
+++ b/checkstyle/checkstyle.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!DOCTYPE module PUBLIC
+		"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
+		"https://checkstyle.org/dtds/configuration_1_3.dtd">
+<module name="com.puppycrawl.tools.checkstyle.Checker">
+	<module name="io.spring.javaformat.checkstyle.SpringChecks">
+		<property name="excludes" value="com.puppycrawl.tools.checkstyle.checks.javadoc.JavadocPackageCheck" />
+	</module>
+</module>
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..ff26bef
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+version=0.1.0-SNAPSHOT
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..d4defec
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,38 @@
+[versions]
+pulsar = "2.10.1"
+junit-jupiter = "5.8.2"
+log4j = "2.18.0"
+slf4j = "1.7.36"
+reactor = "3.4.22"
+assertj = "3.23.1"
+testcontainers = "1.17.3"
+jctools = "3.3.0"
+caffeine = "2.9.3"
+checkstyle = '8.45.1'
+spring-javaformat = '0.0.34'
+licenser = "0.6.1"
+
+[libraries]
+pulsar-client-shaded = { module = "org.apache.pulsar:pulsar-client", version.ref = "pulsar" }
+pulsar-client-api = { module = "org.apache.pulsar:pulsar-client-api", version.ref = "pulsar" }
+reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" }
+reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "reactor" }
+junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }
+junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" }
+assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" }
+log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" }
+log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" }
+log4j-slf4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j-impl", version.ref = "log4j" }
+slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
+testcontainers-pulsar = { module = "org.testcontainers:pulsar", version.ref = "testcontainers" }
+jctools-core = { module = "org.jctools:jctools-core", version.ref = "jctools" }
+caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }
+spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "spring-javaformat" }
+spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "spring-javaformat" }
+licenser = { module = "gradle.plugin.org.cadixdev.gradle:licenser", version.ref = "licenser" }
+
+[bundles]
+log4j = ["log4j-api", "log4j-core", "log4j-slf4j-impl", "slf4j-api"]
+
+[plugins]
+versions = "com.github.ben-manes.versions:0.42.0"
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..249e583
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ae04661
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..a69d9cb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,240 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+# Collect all arguments for the java command;
+#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+#     shell script including quotes and variable substitutions, so put them in
+#     double quotes to make sure that they get re-expanded; and
+#   * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..f127cfd
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,91 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/idea/codeStyleConfig.xml b/idea/codeStyleConfig.xml
new file mode 100644
index 0000000..cd1c76f
--- /dev/null
+++ b/idea/codeStyleConfig.xml
@@ -0,0 +1,128 @@
+<code_scheme name="SpringPulsar" version="173">
+	<option name="AUTODETECT_INDENTS" value="false"/>
+	<option name="OTHER_INDENT_OPTIONS">
+		<value>
+			<option name="USE_TAB_CHARACTER" value="true"/>
+		</value>
+	</option>
+	<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50"/>
+	<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
+	<option name="IMPORT_LAYOUT_TABLE">
+		<value>
+			<package name="java" withSubpackages="true" static="false"/>
+			<emptyLine/>
+			<package name="javax" withSubpackages="true" static="false"/>
+			<emptyLine/>
+			<package name="" withSubpackages="true" static="false"/>
+			<emptyLine/>
+			<package name="org.springframework" withSubpackages="true" static="false"/>
+			<emptyLine/>
+			<package name="" withSubpackages="true" static="true"/>
+		</value>
+	</option>
+	<option name="RIGHT_MARGIN" value="90"/>
+	<option name="ENABLE_JAVADOC_FORMATTING" value="false"/>
+	<option name="JD_ALIGN_PARAM_COMMENTS" value="false"/>
+	<option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false"/>
+	<option name="JD_KEEP_EMPTY_LINES" value="false"/>
+	<GroovyCodeStyleSettings>
+		<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
+		<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
+		<option name="IMPORT_LAYOUT_TABLE">
+			<value>
+				<emptyLine/>
+				<package name="javax" withSubpackages="true" static="false"/>
+				<package name="java" withSubpackages="true" static="false"/>
+				<emptyLine/>
+				<package name="" withSubpackages="true" static="false"/>
+				<emptyLine/>
+				<package name="org.springframework" withSubpackages="true"
+						 static="false"/>
+				<emptyLine/>
+				<package name="" withSubpackages="true" static="true"/>
+			</value>
+		</option>
+	</GroovyCodeStyleSettings>
+	<JavaCodeStyleSettings>
+		<option name="CLASS_NAMES_IN_JAVADOC" value="3"/>
+		<option name="INSERT_INNER_CLASS_IMPORTS" value="true"/>
+		<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="50"/>
+		<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="500"/>
+		<option name="PACKAGES_TO_USE_IMPORT_ON_DEMAND">
+			<value/>
+		</option>
+		<option name="IMPORT_LAYOUT_TABLE">
+			<value>
+				<package name="java" withSubpackages="true" static="false"/>
+				<emptyLine/>
+				<package name="javax" withSubpackages="true" static="false"/>
+				<emptyLine/>
+				<package name="" withSubpackages="true" static="false"/>
+				<emptyLine/>
+				<package name="org.springframework" withSubpackages="true"
+						 static="false"/>
+				<emptyLine/>
+				<package name="" withSubpackages="true" static="true"/>
+			</value>
+		</option>
+		<option name="ENABLE_JAVADOC_FORMATTING" value="false"/>
+		<option name="JD_ALIGN_PARAM_COMMENTS" value="false"/>
+		<option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false"/>
+		<option name="JD_KEEP_INVALID_TAGS" value="false"/>
+		<option name="JD_KEEP_EMPTY_LINES" value="false"/>
+	</JavaCodeStyleSettings>
+	<JetCodeStyleSettings>
+		<option name="PACKAGES_TO_USE_STAR_IMPORTS">
+			<value>
+				<package name="java.util" withSubpackages="false" static="false"/>
+				<package name="kotlinx.android.synthetic" withSubpackages="false"
+						 static="false"/>
+			</value>
+		</option>
+		<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="20"/>
+		<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="20"/>
+	</JetCodeStyleSettings>
+	<XML>
+		<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true"/>
+	</XML>
+	<editorconfig>
+		<option name="ENABLED" value="false"/>
+	</editorconfig>
+	<codeStyleSettings language="Groovy">
+		<indentOptions>
+			<option name="USE_TAB_CHARACTER" value="true"/>
+		</indentOptions>
+	</codeStyleSettings>
+	<codeStyleSettings language="JAVA">
+		<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1"/>
+		<option name="BLANK_LINES_AROUND_FIELD" value="1"/>
+		<option name="BLANK_LINES_AROUND_FIELD_IN_INTERFACE" value="1"/>
+		<option name="ELSE_ON_NEW_LINE" value="true"/>
+		<option name="CATCH_ON_NEW_LINE" value="true"/>
+		<option name="FINALLY_ON_NEW_LINE" value="true"/>
+		<option name="ALIGN_MULTILINE_PARAMETERS" value="false"/>
+		<option name="SPACE_WITHIN_ARRAY_INITIALIZER_BRACES" value="true"/>
+		<option name="SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE" value="true"/>
+		<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true"/>
+		<option name="KEEP_SIMPLE_CLASSES_IN_ONE_LINE" value="true"/>
+		<option name="KEEP_MULTIPLE_EXPRESSIONS_IN_ONE_LINE" value="true"/>
+		<indentOptions>
+			<option name="USE_TAB_CHARACTER" value="true"/>
+		</indentOptions>
+	</codeStyleSettings>
+	<codeStyleSettings language="JSON">
+		<indentOptions>
+			<option name="TAB_SIZE" value="2"/>
+		</indentOptions>
+	</codeStyleSettings>
+	<codeStyleSettings language="XML">
+		<indentOptions>
+			<option name="USE_TAB_CHARACTER" value="true"/>
+		</indentOptions>
+	</codeStyleSettings>
+	<codeStyleSettings language="kotlin">
+		<indentOptions>
+			<option name="USE_TAB_CHARACTER" value="true"/>
+		</indentOptions>
+	</codeStyleSettings>
+</code_scheme>
diff --git a/pulsar-client-reactive-adapter/build.gradle b/pulsar-client-reactive-adapter/build.gradle
new file mode 100644
index 0000000..4cdd849
--- /dev/null
+++ b/pulsar-client-reactive-adapter/build.gradle
@@ -0,0 +1,26 @@
+plugins {
+	id 'pulsar-client-reactive.codestyle-conventions'
+	id 'pulsar-client-reactive.library-conventions'
+	id 'pulsar-client-reactive.integration-test-conventions'
+}
+
+dependencies {
+	api project(':pulsar-client-reactive-api')
+	api libs.pulsar.client.shaded
+	api libs.reactor.core
+	api libs.slf4j.api
+
+	testImplementation libs.junit.jupiter
+	testImplementation libs.junit.jupiter.params
+	testImplementation libs.reactor.test
+	testImplementation libs.assertj.core
+	testImplementation libs.bundles.log4j
+
+	intTestImplementation project(':pulsar-client-reactive-producer-cache-caffeine')
+	intTestImplementation libs.junit.jupiter
+	intTestImplementation libs.testcontainers.pulsar
+	intTestImplementation libs.assertj.core
+	intTestImplementation libs.bundles.log4j
+}
+
+description = "Reactive Streams adapter for Apache Pulsar Java client"
diff --git a/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageConsumerE2ETest.java b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageConsumerE2ETest.java
new file mode 100644
index 0000000..2a5ada8
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageConsumerE2ETest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumer;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSender;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ReactiveMessageConsumerE2ETest {
+
+	@Test
+	void shouldConsumeMessages() throws Exception {
+		try (PulsarClient pulsarClient = SingletonPulsarContainer.createPulsarClient();
+				ReactiveMessageSenderCache producerCache = AdaptedReactivePulsarClientFactory.createCache()) {
+			String topicName = "test" + UUID.randomUUID();
+			// create subscription to retain messages
+			pulsarClient.newConsumer(Schema.STRING).topic(topicName).subscriptionName("sub").subscribe().close();
+
+			ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+
+			ReactiveMessageSender<String> messageSender = reactivePulsarClient.messageSender(Schema.STRING)
+					.cache(producerCache).topic(topicName).build();
+			messageSender.sendMessages(Flux.range(1, 100).map(Object::toString).map(MessageSpec::of)).blockLast();
+
+			ReactiveMessageConsumer<String> messageConsumer = reactivePulsarClient.messageConsumer(Schema.STRING)
+					.topic(topicName).subscriptionName("sub").build();
+			List<String> messages = messageConsumer
+					.consumeMessages((messageFlux) -> messageFlux
+							.map((message) -> MessageResult.acknowledge(message.getMessageId(), message.getValue())))
+					.take(Duration.ofSeconds(2)).collectList().block();
+
+			assertThat(messages).isEqualTo(Flux.range(1, 100).map(Object::toString).collectList().block());
+
+			// should have acknowledged all messages
+			List<Message<String>> remainingMessages = messageConsumer
+					.consumeMessages((messageFlux) -> messageFlux.map(MessageResult::acknowledgeAndReturn))
+					.take(Duration.ofSeconds(2)).collectList().block();
+			assertThat(remainingMessages).isEmpty();
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessagePipelineE2ETest.java b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessagePipelineE2ETest.java
new file mode 100644
index 0000000..a0927eb
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessagePipelineE2ETest.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.MessageSpecBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessagePipeline;
+import org.apache.pulsar.reactive.client.api.ReactiveMessagePipelineBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSender;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ReactiveMessagePipelineE2ETest {
+
+	static final int KEYS_COUNT = 100;
+
+	static final int ITEMS_PER_KEY_COUNT = 100;
+
+	@Test
+	void shouldConsumeMessages() throws Exception {
+		try (PulsarClient pulsarClient = SingletonPulsarContainer.createPulsarClient()) {
+			String topicName = "test" + UUID.randomUUID();
+			// create subscription to retain messages
+			pulsarClient.newConsumer(Schema.STRING).topic(topicName).subscriptionName("sub").subscribe().close();
+
+			ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+
+			ReactiveMessageSender<String> messageSender = reactivePulsarClient.messageSender(Schema.STRING)
+					.topic(topicName).build();
+			messageSender.sendMessages(Flux.range(1, 100).map(Object::toString).map(MessageSpec::of)).blockLast();
+
+			List<String> messages = Collections.synchronizedList(new ArrayList<>());
+			CountDownLatch latch = new CountDownLatch(100);
+
+			try (ReactiveMessagePipeline reactiveMessagePipeline = reactivePulsarClient
+					.messagePipeline(reactivePulsarClient.messageConsumer(Schema.STRING).subscriptionName("sub")
+							.topic(topicName).build())
+					.messageHandler((message) -> Mono.fromRunnable(() -> {
+						messages.add(message.getValue());
+						latch.countDown();
+					})).build().start()) {
+				latch.await(5, TimeUnit.SECONDS);
+				assertThat(messages).isEqualTo(Flux.range(1, 100).map(Object::toString).collectList().block());
+			}
+		}
+	}
+
+	@ParameterizedTest
+	@EnumSource(MessageOrderScenario.class)
+	void shouldRetainMessageOrder(MessageOrderScenario messageOrderScenario) throws Exception {
+		try (PulsarClient pulsarClient = SingletonPulsarContainer.createPulsarClient()) {
+			String topicName = "test" + UUID.randomUUID();
+			// create subscription to retain messages
+			pulsarClient.newConsumer(Schema.INT32).topic(topicName).subscriptionName("sub").subscribe().close();
+
+			ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+
+			ReactiveMessageSender<Integer> messageSender = reactivePulsarClient.messageSender(Schema.INT32)
+					.topic(topicName).build();
+
+			List<MessageSpec<Integer>> messageSpecs = generateRandomOrderedMessagesWhereSingleKeyIsOrdered(
+					messageOrderScenario);
+
+			messageSender.sendMessages(Flux.fromIterable(messageSpecs)).blockLast();
+
+			ConcurrentMap<Integer, List<Integer>> messages = new ConcurrentHashMap<>();
+			CountDownLatch latch = new CountDownLatch(messageSpecs.size());
+
+			List<Integer> orderedSequence = IntStream.rangeClosed(1, ITEMS_PER_KEY_COUNT).boxed()
+					.collect(Collectors.toList());
+
+			ReactiveMessagePipelineBuilder.OneByOneMessagePipelineBuilder<Integer> reactiveMessageHandlerBuilder = reactivePulsarClient
+					.messagePipeline(reactivePulsarClient.messageConsumer(Schema.INT32).subscriptionName("sub")
+							.topic(topicName).build())
+					.messageHandler((message) -> {
+						Mono<Void> messageHandler = Mono.fromRunnable(() -> {
+							Integer keyId = Integer.parseInt(message.getProperty("keyId"));
+							messages.compute(keyId, (k, list) -> {
+								if (list == null) {
+									list = new ArrayList<>();
+								}
+								list.add(message.getValue());
+								return list;
+							});
+							latch.countDown();
+						});
+						if (messageOrderScenario != MessageOrderScenario.NO_PARALLEL) {
+							// add delay which would lead to the execution timeout unless
+							// messages are handled in parallel
+							messageHandler = Mono.delay(Duration.ofMillis(5)).then(messageHandler);
+						}
+						return messageHandler;
+					});
+			if (messageOrderScenario != MessageOrderScenario.NO_PARALLEL) {
+				reactiveMessageHandlerBuilder.concurrent().concurrency(KEYS_COUNT).useKeyOrderedProcessing();
+			}
+			try (ReactiveMessagePipeline reactiveMessagePipeline = reactiveMessageHandlerBuilder.build().start()) {
+				boolean latchCompleted = latch.await(5, TimeUnit.SECONDS);
+				assertThat(latchCompleted).as("processing of all messages should have completed").isTrue();
+				for (int i = 1; i <= KEYS_COUNT; i++) {
+					assertThat(messages.get(i)).as("keyId %d", i).containsExactlyElementsOf(orderedSequence);
+				}
+			}
+		}
+	}
+
+	private List<MessageSpec<Integer>> generateRandomOrderedMessagesWhereSingleKeyIsOrdered(
+			final MessageOrderScenario messageOrderScenario) {
+		List<Queue<MessageSpec<Integer>>> remainingMessages = Flux.range(1, KEYS_COUNT).concatMap((keyId) -> {
+			String keyIdString = keyId.toString();
+			byte[] keyBytes = ByteBuffer.allocate(4).putInt(keyId).array();
+			return Flux.range(1, ITEMS_PER_KEY_COUNT).map((i) -> {
+				MessageSpecBuilder<Integer> messageSpecBuilder = MessageSpec.builder(i).property("keyId", keyIdString);
+				switch (messageOrderScenario) {
+					case PARALLEL_PASS_KEY_IN_MESSAGEKEY:
+						messageSpecBuilder.key(keyIdString);
+						break;
+					case PARALLEL_PASS_KEY_IN_ORDERINGKEY:
+						messageSpecBuilder.orderingKey(keyBytes);
+						break;
+					case NO_PARALLEL:
+						break;
+				}
+				return Tuples.of(keyId, messageSpecBuilder.build());
+			});
+		}).collectMultimap(Tuple2::getT1, Tuple2::getT2).map(Map::values).block().stream().map(LinkedBlockingQueue::new)
+				.collect(Collectors.toList());
+
+		List<MessageSpec<Integer>> messageSpecs = new ArrayList<>(KEYS_COUNT * ITEMS_PER_KEY_COUNT);
+		while (messageSpecs.size() < KEYS_COUNT * ITEMS_PER_KEY_COUNT) {
+			int randomIndex = ThreadLocalRandom.current().nextInt(remainingMessages.size());
+			Queue<MessageSpec<Integer>> specsForKey = remainingMessages.get(randomIndex);
+			MessageSpec<Integer> messageSpec = specsForKey.poll();
+			messageSpecs.add(messageSpec);
+			if (specsForKey.size() == 0) {
+				remainingMessages.remove(randomIndex);
+			}
+		}
+		return messageSpecs;
+	}
+
+	enum MessageOrderScenario {
+
+		NO_PARALLEL, PARALLEL_PASS_KEY_IN_ORDERINGKEY, PARALLEL_PASS_KEY_IN_MESSAGEKEY
+
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageReaderE2ETest.java b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageReaderE2ETest.java
new file mode 100644
index 0000000..ed7a7f9
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageReaderE2ETest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReader;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSender;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ReactiveMessageReaderE2ETest {
+
+	@Test
+	void shouldReadMessages() throws Exception {
+		try (PulsarClient pulsarClient = SingletonPulsarContainer.createPulsarClient();
+				ReactiveMessageSenderCache producerCache = AdaptedReactivePulsarClientFactory.createCache()) {
+			String topicName = "test" + UUID.randomUUID();
+			// create subscription to retain messages
+			pulsarClient.newConsumer(Schema.STRING).topic(topicName).subscriptionName("sub").subscribe().close();
+
+			ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+
+			ReactiveMessageSender<String> messageSender = reactivePulsarClient.messageSender(Schema.STRING)
+					.cache(producerCache).topic(topicName).build();
+			messageSender.sendMessages(Flux.range(1, 100).map(Object::toString).map(MessageSpec::of)).blockLast();
+
+			ReactiveMessageReader<String> messageReader = reactivePulsarClient.messageReader(Schema.STRING)
+					.topic(topicName).build();
+			List<String> messages = messageReader.readMessages().map(Message::getValue).collectList().block();
+
+			assertThat(messages).isEqualTo(Flux.range(1, 100).map(Object::toString).collectList().block());
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageSenderE2ETest.java b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageSenderE2ETest.java
new file mode 100644
index 0000000..927f715
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/ReactiveMessageSenderE2ETest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.util.Arrays;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSender;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.apache.pulsar.reactive.client.internal.adapter.ConcurrentHashMapProducerCacheProvider;
+import org.apache.pulsar.reactive.client.producercache.CaffeineProducerCacheProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ReactiveMessageSenderE2ETest {
+
+	private static Stream<Arguments> shouldSendMessageToTopicWithCachedProducer() {
+		return Arrays
+				.asList(Arguments.of("ConcurrentHashMapProducerCacheProvider",
+						AdaptedReactivePulsarClientFactory.createCache(new ConcurrentHashMapProducerCacheProvider())),
+						Arguments.of("Default", AdaptedReactivePulsarClientFactory.createCache()),
+						Arguments.of("CaffeineProducerCacheProvider",
+								AdaptedReactivePulsarClientFactory.createCache(new CaffeineProducerCacheProvider())))
+				.stream();
+	}
+
+	@Test
+	void shouldSendMessageToTopic() throws PulsarClientException {
+		try (PulsarClient pulsarClient = SingletonPulsarContainer.createPulsarClient()) {
+			String topicName = "test" + UUID.randomUUID();
+			Consumer<String> consumer = pulsarClient.newConsumer(Schema.STRING).topic(topicName).subscriptionName("sub")
+					.subscribe();
+
+			ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+
+			ReactiveMessageSender<String> messageSender = reactivePulsarClient.messageSender(Schema.STRING)
+					.topic(topicName).maxInflight(1).build();
+			MessageId messageId = messageSender.sendMessage(Mono.just(MessageSpec.of("Hello world!"))).block();
+			assertThat(messageId).isNotNull();
+
+			Message<String> message = consumer.receive(1, TimeUnit.SECONDS);
+			assertThat(message).isNotNull();
+			assertThat(message.getValue()).isEqualTo("Hello world!");
+		}
+	}
+
+	@ParameterizedTest
+	@MethodSource
+	void shouldSendMessageToTopicWithCachedProducer(String name, ReactiveMessageSenderCache cacheInstance)
+			throws Exception {
+		try (PulsarClient pulsarClient = SingletonPulsarContainer.createPulsarClient();
+				ReactiveMessageSenderCache producerCache = cacheInstance) {
+			String topicName = "test" + UUID.randomUUID();
+			Consumer<String> consumer = pulsarClient.newConsumer(Schema.STRING).topic(topicName).subscriptionName("sub")
+					.subscribe();
+
+			ReactivePulsarClient reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
+
+			ReactiveMessageSender<String> messageSender = reactivePulsarClient.messageSender(Schema.STRING)
+					.cache(producerCache).maxInflight(1).topic(topicName).build();
+			MessageId messageId = messageSender.sendMessage(Mono.just(MessageSpec.of("Hello world!"))).block();
+			assertThat(messageId).isNotNull();
+
+			Message<String> message = consumer.receive(1, TimeUnit.SECONDS);
+			assertThat(message).isNotNull();
+			assertThat(message.getValue()).isEqualTo("Hello world!");
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/SingletonPulsarContainer.java b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/SingletonPulsarContainer.java
new file mode 100644
index 0000000..a2b8039
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/intTest/java/org/apache/pulsar/reactive/client/adapter/SingletonPulsarContainer.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.PulsarClientException;
+import org.testcontainers.containers.PulsarContainer;
+import org.testcontainers.utility.DockerImageName;
+
+public final class SingletonPulsarContainer {
+
+	private SingletonPulsarContainer() {
+
+	}
+
+	/** The singleton instance for Pulsar container. */
+	public static PulsarContainer PULSAR_CONTAINER = new PulsarContainer(
+			DockerImageName.parse("apachepulsar/pulsar").withTag("2.10.1"));
+
+	static {
+		PULSAR_CONTAINER.start();
+	}
+
+	public static PulsarClient createPulsarClient() throws PulsarClientException {
+		return PulsarClient.builder().serviceUrl(SingletonPulsarContainer.PULSAR_CONTAINER.getPulsarBrokerUrl())
+				.build();
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/intTest/resources/log4j2-test.xml b/pulsar-client-reactive-adapter/src/intTest/resources/log4j2-test.xml
new file mode 100644
index 0000000..2171228
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/intTest/resources/log4j2-test.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Copyright 2022 the original author or authors.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         https://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+
+<Configuration status="WARN">
+	<Appenders>
+		<Console name="STDOUT" target="SYSTEM_OUT">
+			<PatternLayout pattern="[%d] [%t] [%c] %p %m%n"/>
+		</Console>
+	</Appenders>
+	<Loggers>
+		<Logger name="reactor" level="info"/>
+		<Logger name="com.github.lhotari" level="trace"/>
+		<Root level="off">
+			<AppenderRef ref="STDOUT"/>
+		</Root>
+	</Loggers>
+</Configuration>
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/AdaptedReactivePulsarClientFactory.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/AdaptedReactivePulsarClientFactory.java
new file mode 100644
index 0000000..fe730d1
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/AdaptedReactivePulsarClientFactory.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import org.apache.pulsar.reactive.client.internal.adapter.AdapterImplementationFactory;
+
+public final class AdaptedReactivePulsarClientFactory {
+
+	private AdaptedReactivePulsarClientFactory() {
+
+	}
+
+	/**
+	 * Creates a ReactivePulsarClient by wrapping an existing PulsarClient instance.
+	 * @param pulsarClient the Pulsar Client instance to use
+	 * @return a ReactivePulsarClient instance
+	 */
+	public static ReactivePulsarClient create(PulsarClient pulsarClient) {
+		return create(() -> pulsarClient);
+	}
+
+	/**
+	 * Creates a ReactivePulsarClient which will lazily call the provided supplier to get
+	 * an instance of a Pulsar Client when needed.
+	 * @param pulsarClientSupplier the supplier to use for getting a Pulsar Client
+	 * instance
+	 * @return a ReactivePulsarClient instance
+	 */
+	public static ReactivePulsarClient create(Supplier<PulsarClient> pulsarClientSupplier) {
+		return AdapterImplementationFactory.createReactivePulsarClient(pulsarClientSupplier);
+	}
+
+	public static ReactiveMessageSenderCache createCache(ProducerCacheProvider producerCacheProvider) {
+		return AdapterImplementationFactory.createCache(producerCacheProvider);
+	}
+
+	public static ReactiveMessageSenderCache createCache() {
+		return AdapterImplementationFactory.createCache();
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/DefaultMessageGroupingFunction.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/DefaultMessageGroupingFunction.java
new file mode 100644
index 0000000..b8e868e
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/DefaultMessageGroupingFunction.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.impl.Murmur3Hash32;
+import org.apache.pulsar.reactive.client.api.MessageGroupingFunction;
+
+public class DefaultMessageGroupingFunction implements MessageGroupingFunction {
+
+	private static byte[] getMessageKeyBytes(Message<?> message) {
+		byte[] keyBytes = null;
+		if (message.hasOrderingKey()) {
+			keyBytes = message.getOrderingKey();
+		}
+		else if (message.hasKey()) {
+			keyBytes = message.getKeyBytes();
+		}
+		if (keyBytes == null || keyBytes.length == 0) {
+			// use a group that has been derived from the message id so that redeliveries
+			// get handled in order
+			keyBytes = message.getMessageId().toByteArray();
+		}
+		return keyBytes;
+	}
+
+	@Override
+	public int resolveProcessingGroup(Message<?> message, int numberOfGroups) {
+		byte[] keyBytes = getMessageKeyBytes(message);
+		int keyHash = Murmur3Hash32.getInstance().makeHash(keyBytes);
+		return keyHash % numberOfGroups;
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/ProducerCacheProvider.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/ProducerCacheProvider.java
new file mode 100644
index 0000000..782bd4e
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/ProducerCacheProvider.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+public interface ProducerCacheProvider extends AutoCloseable {
+
+	<K, V> CompletableFuture<V> getOrCreateCachedEntry(K key, Function<K, CompletableFuture<V>> createEntryFunction);
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/ProducerCacheProviderFactory.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/ProducerCacheProviderFactory.java
new file mode 100644
index 0000000..f3e496a
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/adapter/ProducerCacheProviderFactory.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.adapter;
+
+import java.util.function.Supplier;
+
+public interface ProducerCacheProviderFactory extends Supplier<ProducerCacheProvider> {
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageConsumer.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageConsumer.java
new file mode 100644
index 0000000..d3b5240
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageConsumer.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumer;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerSpec;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.SynchronousSink;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+
+class AdaptedReactiveMessageConsumer<T> implements ReactiveMessageConsumer<T> {
+
+	private final ReactiveConsumerAdapterFactory reactiveConsumerAdapterFactory;
+
+	private final Schema<T> schema;
+
+	private final ReactiveMessageConsumerSpec consumerSpec;
+
+	private final boolean acknowledgeAsynchronously;
+
+	AdaptedReactiveMessageConsumer(ReactiveConsumerAdapterFactory reactiveConsumerAdapterFactory, Schema<T> schema,
+			ReactiveMessageConsumerSpec consumerSpec) {
+		this.reactiveConsumerAdapterFactory = reactiveConsumerAdapterFactory;
+		this.schema = schema;
+		this.consumerSpec = consumerSpec;
+		this.acknowledgeAsynchronously = consumerSpec.getAcknowledgeAsynchronously() == null
+				|| consumerSpec.getAcknowledgeAsynchronously() != null && consumerSpec.getAcknowledgeAsynchronously();
+	}
+
+	static <T> Mono<Message<T>> readNextMessage(Consumer<T> consumer) {
+		return PulsarFutureAdapter.adaptPulsarFuture(consumer::receiveAsync);
+	}
+
+	@Override
+	public <R> Mono<R> consumeMessage(Function<Mono<Message<T>>, Mono<MessageResult<R>>> messageHandler) {
+		return createReactiveConsumerAdapter().usingConsumer((consumer) -> Mono.using(this::pinAcknowledgeScheduler,
+				(pinnedAcknowledgeScheduler) -> messageHandler.apply(readNextMessage(consumer)).delayUntil(
+						(messageResult) -> handleAcknowledgement(consumer, messageResult, pinnedAcknowledgeScheduler))
+						.handle(this::handleMessageResult),
+				Scheduler::dispose));
+	}
+
+	private Scheduler pinAcknowledgeScheduler() {
+		return Schedulers.single((this.consumerSpec.getAcknowledgeScheduler() != null)
+				? this.consumerSpec.getAcknowledgeScheduler() : Schedulers.boundedElastic());
+	}
+
+	private <R> Mono<?> handleAcknowledgement(Consumer<T> consumer, MessageResult<R> messageResult,
+			Scheduler pinnedAcknowledgeScheduler) {
+		if (messageResult.getMessageId() != null) {
+			Mono<Void> acknowledgementMono;
+			if (messageResult.isAcknowledgeMessage()) {
+				acknowledgementMono = Mono.fromFuture(() -> consumer.acknowledgeAsync(messageResult.getMessageId()));
+			}
+			else {
+				acknowledgementMono = Mono
+						.fromRunnable(() -> consumer.negativeAcknowledge(messageResult.getMessageId()));
+			}
+			acknowledgementMono = acknowledgementMono.subscribeOn(pinnedAcknowledgeScheduler);
+			if (this.acknowledgeAsynchronously) {
+				return Mono.fromRunnable(acknowledgementMono::subscribe);
+			}
+			else {
+				return acknowledgementMono;
+			}
+		}
+		else {
+			return Mono.empty();
+		}
+	}
+
+	private ReactiveConsumerAdapter<T> createReactiveConsumerAdapter() {
+		return this.reactiveConsumerAdapterFactory.create((pulsarClient) -> {
+			ConsumerBuilder<T> consumerBuilder = pulsarClient.newConsumer(this.schema);
+			configureConsumerBuilder(consumerBuilder);
+			return consumerBuilder;
+		});
+	}
+
+	private void configureConsumerBuilder(ConsumerBuilder<T> consumerBuilder) {
+		if (this.consumerSpec.getTopicNames() != null && !this.consumerSpec.getTopicNames().isEmpty()) {
+			consumerBuilder.topics(this.consumerSpec.getTopicNames());
+		}
+		if (this.consumerSpec.getTopicsPattern() != null) {
+			consumerBuilder.topicsPattern(this.consumerSpec.getTopicsPattern());
+		}
+		if (this.consumerSpec.getTopicsPatternSubscriptionMode() != null) {
+			consumerBuilder.subscriptionTopicsMode(this.consumerSpec.getTopicsPatternSubscriptionMode());
+		}
+		if (this.consumerSpec.getTopicsPatternAutoDiscoveryPeriod() != null) {
+			consumerBuilder.patternAutoDiscoveryPeriod(
+					(int) (this.consumerSpec.getTopicsPatternAutoDiscoveryPeriod().toMillis() / 1000L),
+					TimeUnit.SECONDS);
+		}
+		if (this.consumerSpec.getSubscriptionName() != null) {
+			consumerBuilder.subscriptionName(this.consumerSpec.getSubscriptionName());
+		}
+		if (this.consumerSpec.getSubscriptionMode() != null) {
+			consumerBuilder.subscriptionMode(this.consumerSpec.getSubscriptionMode());
+		}
+		if (this.consumerSpec.getSubscriptionType() != null) {
+			consumerBuilder.subscriptionType(this.consumerSpec.getSubscriptionType());
+		}
+		if (this.consumerSpec.getKeySharedPolicy() != null) {
+			consumerBuilder.keySharedPolicy(this.consumerSpec.getKeySharedPolicy());
+		}
+		if (this.consumerSpec.getReplicateSubscriptionState() != null) {
+			consumerBuilder.replicateSubscriptionState(this.consumerSpec.getReplicateSubscriptionState());
+		}
+		if (this.consumerSpec.getSubscriptionProperties() != null
+				&& !this.consumerSpec.getSubscriptionProperties().isEmpty()) {
+			consumerBuilder.subscriptionProperties(this.consumerSpec.getSubscriptionProperties());
+		}
+		if (this.consumerSpec.getConsumerName() != null) {
+			consumerBuilder.consumerName(this.consumerSpec.getConsumerName());
+		}
+		if (this.consumerSpec.getProperties() != null && !this.consumerSpec.getProperties().isEmpty()) {
+			consumerBuilder.properties(this.consumerSpec.getProperties());
+		}
+		if (this.consumerSpec.getPriorityLevel() != null) {
+			consumerBuilder.priorityLevel(this.consumerSpec.getPriorityLevel());
+		}
+		if (this.consumerSpec.getReadCompacted() != null) {
+			consumerBuilder.readCompacted(this.consumerSpec.getReadCompacted());
+		}
+		if (this.consumerSpec.getBatchIndexAckEnabled() != null) {
+			consumerBuilder.enableBatchIndexAcknowledgment(this.consumerSpec.getBatchIndexAckEnabled());
+		}
+		if (this.consumerSpec.getAckTimeout() != null) {
+			consumerBuilder.ackTimeout(this.consumerSpec.getAckTimeout().toMillis(), TimeUnit.MILLISECONDS);
+		}
+		if (this.consumerSpec.getAckTimeoutTickTime() != null) {
+			consumerBuilder.ackTimeoutTickTime(this.consumerSpec.getAckTimeoutTickTime().toMillis(),
+					TimeUnit.MILLISECONDS);
+		}
+		if (this.consumerSpec.getAcknowledgementsGroupTime() != null) {
+			consumerBuilder.acknowledgmentGroupTime(this.consumerSpec.getAcknowledgementsGroupTime().toMillis(),
+					TimeUnit.MILLISECONDS);
+		}
+		if (this.consumerSpec.getNegativeAckRedeliveryDelay() != null) {
+			consumerBuilder.negativeAckRedeliveryDelay(this.consumerSpec.getNegativeAckRedeliveryDelay().toMillis(),
+					TimeUnit.MILLISECONDS);
+		}
+		if (this.consumerSpec.getDeadLetterPolicy() != null) {
+			consumerBuilder.deadLetterPolicy(this.consumerSpec.getDeadLetterPolicy());
+		}
+		if (this.consumerSpec.getRetryLetterTopicEnable() != null) {
+			consumerBuilder.enableRetry(this.consumerSpec.getRetryLetterTopicEnable());
+		}
+		if (this.consumerSpec.getReceiverQueueSize() != null) {
+			consumerBuilder.receiverQueueSize(this.consumerSpec.getReceiverQueueSize());
+		}
+		if (this.consumerSpec.getMaxTotalReceiverQueueSizeAcrossPartitions() != null) {
+			consumerBuilder.maxTotalReceiverQueueSizeAcrossPartitions(
+					this.consumerSpec.getMaxTotalReceiverQueueSizeAcrossPartitions());
+		}
+		if (this.consumerSpec.getAutoUpdatePartitions() != null) {
+			consumerBuilder.autoUpdatePartitions(this.consumerSpec.getAutoUpdatePartitions());
+		}
+		if (this.consumerSpec.getAutoUpdatePartitionsInterval() != null) {
+			consumerBuilder.autoUpdatePartitionsInterval(
+					(int) (this.consumerSpec.getAutoUpdatePartitionsInterval().toMillis() / 1000L), TimeUnit.SECONDS);
+		}
+		if (this.consumerSpec.getCryptoKeyReader() != null) {
+			consumerBuilder.cryptoKeyReader(this.consumerSpec.getCryptoKeyReader());
+		}
+		if (this.consumerSpec.getCryptoFailureAction() != null) {
+			consumerBuilder.cryptoFailureAction(this.consumerSpec.getCryptoFailureAction());
+		}
+		if (this.consumerSpec.getMaxPendingChunkedMessage() != null) {
+			consumerBuilder.maxPendingChunkedMessage(this.consumerSpec.getMaxPendingChunkedMessage());
+		}
+		if (this.consumerSpec.getAutoAckOldestChunkedMessageOnQueueFull() != null) {
+			consumerBuilder.autoAckOldestChunkedMessageOnQueueFull(
+					this.consumerSpec.getAutoAckOldestChunkedMessageOnQueueFull());
+		}
+		if (this.consumerSpec.getExpireTimeOfIncompleteChunkedMessage() != null) {
+			consumerBuilder.expireTimeOfIncompleteChunkedMessage(
+					this.consumerSpec.getExpireTimeOfIncompleteChunkedMessage().toMillis(), TimeUnit.MILLISECONDS);
+		}
+	}
+
+	@Override
+	public <R> Flux<R> consumeMessages(Function<Flux<Message<T>>, Flux<MessageResult<R>>> messageHandler) {
+		return createReactiveConsumerAdapter().usingConsumerMany((consumer) -> Flux.using(this::pinAcknowledgeScheduler,
+				(pinnedAcknowledgeScheduler) -> messageHandler.apply(readNextMessage(consumer).repeat()).delayUntil(
+						(messageResult) -> handleAcknowledgement(consumer, messageResult, pinnedAcknowledgeScheduler))
+						.handle(this::handleMessageResult),
+				Scheduler::dispose));
+	}
+
+	@Override
+	public Mono<Void> consumeNothing() {
+		return createReactiveConsumerAdapter().usingConsumer((consumer) -> Mono.empty());
+	}
+
+	private <R> void handleMessageResult(MessageResult<R> messageResult, SynchronousSink<R> sink) {
+		R value = messageResult.getValue();
+		if (value != null) {
+			sink.next(value);
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageConsumerBuilder.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageConsumerBuilder.java
new file mode 100644
index 0000000..b69381b
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageConsumerBuilder.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.ImmutableReactiveMessageConsumerSpec;
+import org.apache.pulsar.reactive.client.api.MutableReactiveMessageConsumerSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumer;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerSpec;
+
+class AdaptedReactiveMessageConsumerBuilder<T> implements ReactiveMessageConsumerBuilder<T> {
+
+	private final Schema<T> schema;
+
+	private final ReactiveConsumerAdapterFactory reactiveConsumerAdapterFactory;
+
+	private final MutableReactiveMessageConsumerSpec consumerSpec = new MutableReactiveMessageConsumerSpec();
+
+	AdaptedReactiveMessageConsumerBuilder(Schema<T> schema,
+			ReactiveConsumerAdapterFactory reactiveConsumerAdapterFactory) {
+		this.schema = schema;
+		this.reactiveConsumerAdapterFactory = reactiveConsumerAdapterFactory;
+	}
+
+	@Override
+	public ReactiveMessageConsumerSpec toImmutableSpec() {
+		return new ImmutableReactiveMessageConsumerSpec(this.consumerSpec);
+	}
+
+	@Override
+	public MutableReactiveMessageConsumerSpec getMutableSpec() {
+		return this.consumerSpec;
+	}
+
+	@Override
+	public ReactiveMessageConsumer<T> build() {
+		return new AdaptedReactiveMessageConsumer<T>(this.reactiveConsumerAdapterFactory, this.schema,
+				toImmutableSpec());
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageReader.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageReader.java
new file mode 100644
index 0000000..76abb2e
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageReader.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Range;
+import org.apache.pulsar.client.api.Reader;
+import org.apache.pulsar.client.api.ReaderBuilder;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.EndOfStreamAction;
+import org.apache.pulsar.reactive.client.api.InstantStartAtSpec;
+import org.apache.pulsar.reactive.client.api.MessageIdStartAtSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReader;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderSpec;
+import org.apache.pulsar.reactive.client.api.StartAtSpec;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+class AdaptedReactiveMessageReader<T> implements ReactiveMessageReader<T> {
+
+	private final Schema<T> schema;
+
+	private final ReactiveMessageReaderSpec readerSpec;
+
+	private final ReactiveReaderAdapterFactory reactiveReaderAdapterFactory;
+
+	private final StartAtSpec startAtSpec;
+
+	private final EndOfStreamAction endOfStreamAction;
+
+	AdaptedReactiveMessageReader(ReactiveReaderAdapterFactory reactiveReaderAdapterFactory, Schema<T> schema,
+			ReactiveMessageReaderSpec readerSpec, StartAtSpec startAtSpec, EndOfStreamAction endOfStreamAction) {
+		this.schema = schema;
+		this.readerSpec = readerSpec;
+		this.reactiveReaderAdapterFactory = reactiveReaderAdapterFactory;
+		this.startAtSpec = startAtSpec;
+		this.endOfStreamAction = endOfStreamAction;
+	}
+
+	static <T> Mono<Message<T>> readNextMessage(Reader<T> reader, EndOfStreamAction endOfStreamAction) {
+		Mono<Message<T>> messageMono = PulsarFutureAdapter.adaptPulsarFuture(reader::readNextAsync);
+		if (endOfStreamAction == EndOfStreamAction.COMPLETE) {
+			return PulsarFutureAdapter.adaptPulsarFuture(reader::hasMessageAvailableAsync).filter(Boolean::booleanValue)
+					.flatMap((__) -> messageMono);
+		}
+		else {
+			return messageMono;
+		}
+	}
+
+	ReactiveReaderAdapter<T> createReactiveReaderAdapter(StartAtSpec startAtSpec) {
+		return this.reactiveReaderAdapterFactory.create(readerStartingAt(startAtSpec));
+	}
+
+	private Function<PulsarClient, ReaderBuilder<T>> readerStartingAt(StartAtSpec startAtSpec) {
+		return (pulsarClient) -> {
+			ReaderBuilder<T> readerBuilder = pulsarClient.newReader(this.schema);
+			if (startAtSpec != null) {
+				if (startAtSpec instanceof MessageIdStartAtSpec) {
+					MessageIdStartAtSpec messageIdStartAtSpec = (MessageIdStartAtSpec) startAtSpec;
+					readerBuilder.startMessageId(messageIdStartAtSpec.getMessageId());
+					if (messageIdStartAtSpec.isInclusive()) {
+						readerBuilder.startMessageIdInclusive();
+					}
+				}
+				else {
+					InstantStartAtSpec instantStartAtSpec = (InstantStartAtSpec) startAtSpec;
+					long rollbackDuration = ChronoUnit.SECONDS.between(instantStartAtSpec.getInstant(), Instant.now())
+							+ 1L;
+					if (rollbackDuration < 0L) {
+						throw new IllegalArgumentException("InstantStartAtSpec must be in the past.");
+					}
+					readerBuilder.startMessageFromRollbackDuration(rollbackDuration, TimeUnit.SECONDS);
+				}
+			}
+			configureReaderBuilder(readerBuilder);
+			return readerBuilder;
+		};
+	}
+
+	private void configureReaderBuilder(ReaderBuilder<T> readerBuilder) {
+		readerBuilder.topics(this.readerSpec.getTopicNames());
+		if (this.readerSpec.getReaderName() != null) {
+			readerBuilder.readerName(this.readerSpec.getReaderName());
+		}
+		if (this.readerSpec.getSubscriptionName() != null) {
+			readerBuilder.subscriptionName(this.readerSpec.getSubscriptionName());
+		}
+		if (this.readerSpec.getGeneratedSubscriptionNamePrefix() != null) {
+			readerBuilder.subscriptionRolePrefix(this.readerSpec.getGeneratedSubscriptionNamePrefix());
+		}
+		if (this.readerSpec.getReceiverQueueSize() != null) {
+			readerBuilder.receiverQueueSize(this.readerSpec.getReceiverQueueSize());
+		}
+		if (this.readerSpec.getReadCompacted() != null) {
+			readerBuilder.readCompacted(this.readerSpec.getReadCompacted());
+		}
+		if (this.readerSpec.getKeyHashRanges() != null && !this.readerSpec.getKeyHashRanges().isEmpty()) {
+			readerBuilder.keyHashRange(this.readerSpec.getKeyHashRanges().toArray(new Range[0]));
+		}
+		if (this.readerSpec.getCryptoKeyReader() != null) {
+			readerBuilder.cryptoKeyReader(this.readerSpec.getCryptoKeyReader());
+		}
+		if (this.readerSpec.getCryptoFailureAction() != null) {
+			readerBuilder.cryptoFailureAction(this.readerSpec.getCryptoFailureAction());
+		}
+	}
+
+	@Override
+	public Mono<Message<T>> readMessage() {
+		return createReactiveReaderAdapter(this.startAtSpec)
+				.usingReader((reader) -> readNextMessage(reader, this.endOfStreamAction));
+	}
+
+	@Override
+	public Flux<Message<T>> readMessages() {
+		return createReactiveReaderAdapter(this.startAtSpec).usingReaderMany((reader) -> {
+			Mono<Message<T>> messageMono = readNextMessage(reader, this.endOfStreamAction);
+			if (this.endOfStreamAction == EndOfStreamAction.COMPLETE) {
+				return messageMono.repeatWhen((flux) -> flux.takeWhile((emitted) -> emitted > 0L));
+			}
+			return messageMono.repeat();
+		});
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageReaderBuilder.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageReaderBuilder.java
new file mode 100644
index 0000000..03888e9
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageReaderBuilder.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.EndOfStreamAction;
+import org.apache.pulsar.reactive.client.api.ImmutableReactiveMessageReaderSpec;
+import org.apache.pulsar.reactive.client.api.MutableReactiveMessageReaderSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReader;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderSpec;
+import org.apache.pulsar.reactive.client.api.StartAtSpec;
+
+class AdaptedReactiveMessageReaderBuilder<T> implements ReactiveMessageReaderBuilder<T> {
+
+	private final ReactiveReaderAdapterFactory reactiveReaderAdapterFactory;
+
+	private final Schema<T> schema;
+
+	private MutableReactiveMessageReaderSpec readerSpec = new MutableReactiveMessageReaderSpec();
+
+	private StartAtSpec startAtSpec = StartAtSpec.ofEarliest();
+
+	private EndOfStreamAction endOfStreamAction = EndOfStreamAction.COMPLETE;
+
+	AdaptedReactiveMessageReaderBuilder(Schema<T> schema, ReactiveReaderAdapterFactory reactiveReaderAdapterFactory) {
+		this.reactiveReaderAdapterFactory = reactiveReaderAdapterFactory;
+		this.schema = schema;
+	}
+
+	@Override
+	public ReactiveMessageReaderBuilder<T> startAtSpec(StartAtSpec startAtSpec) {
+		this.startAtSpec = startAtSpec;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessageReaderBuilder<T> endOfStreamAction(EndOfStreamAction endOfStreamAction) {
+		this.endOfStreamAction = endOfStreamAction;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessageReaderSpec toImmutableSpec() {
+		return new ImmutableReactiveMessageReaderSpec(this.readerSpec);
+	}
+
+	@Override
+	public MutableReactiveMessageReaderSpec getMutableSpec() {
+		return this.readerSpec;
+	}
+
+	@Override
+	public ReactiveMessageReaderBuilder<T> clone() {
+		AdaptedReactiveMessageReaderBuilder<T> cloned = new AdaptedReactiveMessageReaderBuilder<>(this.schema,
+				this.reactiveReaderAdapterFactory);
+		cloned.readerSpec = new MutableReactiveMessageReaderSpec(this.readerSpec);
+		cloned.startAtSpec = this.startAtSpec;
+		cloned.endOfStreamAction = this.endOfStreamAction;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessageReader<T> build() {
+		return new AdaptedReactiveMessageReader<>(this.reactiveReaderAdapterFactory, this.schema, this.readerSpec,
+				this.startAtSpec, this.endOfStreamAction);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageSender.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageSender.java
new file mode 100644
index 0000000..d5843cc
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageSender.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.client.api.TypedMessageBuilder;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSender;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderSpec;
+import org.apache.pulsar.reactive.client.internal.api.InternalMessageSpec;
+import org.apache.pulsar.reactive.client.internal.api.PublisherTransformer;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+class AdaptedReactiveMessageSender<T> implements ReactiveMessageSender<T> {
+
+	private final Schema<T> schema;
+
+	private final ReactiveMessageSenderSpec senderSpec;
+
+	private final int maxConcurrency;
+
+	private final ReactiveProducerAdapterFactory reactiveProducerAdapterFactory;
+
+	private final ProducerCache producerCache;
+
+	private final Supplier<PublisherTransformer> producerActionTransformer;
+
+	AdaptedReactiveMessageSender(Schema<T> schema, ReactiveMessageSenderSpec senderSpec, int maxConcurrency,
+			ReactiveProducerAdapterFactory reactiveProducerAdapterFactory, ProducerCache producerCache,
+			Supplier<PublisherTransformer> producerActionTransformer) {
+		this.schema = schema;
+		this.senderSpec = senderSpec;
+		this.maxConcurrency = maxConcurrency;
+		this.reactiveProducerAdapterFactory = reactiveProducerAdapterFactory;
+		this.producerCache = producerCache;
+		this.producerActionTransformer = producerActionTransformer;
+	}
+
+	ReactiveProducerAdapter<T> createReactiveProducerAdapter() {
+		return this.reactiveProducerAdapterFactory.create((pulsarClient) -> {
+			ProducerBuilder<T> producerBuilder = pulsarClient.newProducer(this.schema);
+			configureProducerBuilder(producerBuilder);
+			return producerBuilder;
+		}, this.producerCache, this.producerActionTransformer);
+	}
+
+	private void configureProducerBuilder(ProducerBuilder<T> producerBuilder) {
+		if (this.senderSpec.getTopicName() != null) {
+			producerBuilder.topic(this.senderSpec.getTopicName());
+		}
+
+		if (this.senderSpec.getProducerName() != null) {
+			producerBuilder.producerName(this.senderSpec.getProducerName());
+		}
+
+		if (this.senderSpec.getSendTimeout() != null) {
+			producerBuilder.sendTimeout((int) (this.senderSpec.getSendTimeout().toMillis() / 1000L), TimeUnit.SECONDS);
+		}
+
+		if (this.senderSpec.getMaxPendingMessages() != null) {
+			producerBuilder.maxPendingMessages(this.senderSpec.getMaxPendingMessages());
+		}
+
+		if (this.senderSpec.getMaxPendingMessagesAcrossPartitions() != null) {
+			producerBuilder.maxPendingMessagesAcrossPartitions(this.senderSpec.getMaxPendingMessagesAcrossPartitions());
+		}
+
+		if (this.senderSpec.getMessageRoutingMode() != null) {
+			producerBuilder.messageRoutingMode(this.senderSpec.getMessageRoutingMode());
+		}
+
+		if (this.senderSpec.getHashingScheme() != null) {
+			producerBuilder.hashingScheme(this.senderSpec.getHashingScheme());
+		}
+
+		if (this.senderSpec.getCryptoFailureAction() != null) {
+			producerBuilder.cryptoFailureAction(this.senderSpec.getCryptoFailureAction());
+		}
+
+		if (this.senderSpec.getMessageRouter() != null) {
+			producerBuilder.messageRouter(this.senderSpec.getMessageRouter());
+		}
+
+		if (this.senderSpec.getBatchingMaxPublishDelay() != null) {
+			producerBuilder.batchingMaxPublishDelay(this.senderSpec.getBatchingMaxPublishDelay().toNanos(),
+					TimeUnit.NANOSECONDS);
+		}
+
+		if (this.senderSpec.getRoundRobinRouterBatchingPartitionSwitchFrequency() != null) {
+			producerBuilder.roundRobinRouterBatchingPartitionSwitchFrequency(
+					this.senderSpec.getRoundRobinRouterBatchingPartitionSwitchFrequency());
+		}
+
+		if (this.senderSpec.getBatchingMaxMessages() != null) {
+			producerBuilder.batchingMaxMessages(this.senderSpec.getBatchingMaxMessages());
+		}
+
+		if (this.senderSpec.getBatchingMaxBytes() != null) {
+			producerBuilder.batchingMaxBytes(this.senderSpec.getBatchingMaxBytes());
+		}
+
+		if (this.senderSpec.getBatchingEnabled() != null) {
+			producerBuilder.enableBatching(this.senderSpec.getBatchingEnabled());
+		}
+
+		if (this.senderSpec.getBatcherBuilder() != null) {
+			producerBuilder.batcherBuilder(this.senderSpec.getBatcherBuilder());
+		}
+
+		if (this.senderSpec.getChunkingEnabled() != null) {
+			producerBuilder.enableChunking(this.senderSpec.getChunkingEnabled());
+		}
+
+		if (this.senderSpec.getCryptoKeyReader() != null) {
+			producerBuilder.cryptoKeyReader(this.senderSpec.getCryptoKeyReader());
+		}
+
+		if (this.senderSpec.getEncryptionKeys() != null && !this.senderSpec.getEncryptionKeys().isEmpty()) {
+			this.senderSpec.getEncryptionKeys().forEach(producerBuilder::addEncryptionKey);
+		}
+
+		if (this.senderSpec.getCompressionType() != null) {
+			producerBuilder.compressionType(this.senderSpec.getCompressionType());
+		}
+
+		if (this.senderSpec.getInitialSequenceId() != null) {
+			producerBuilder.initialSequenceId(this.senderSpec.getInitialSequenceId());
+		}
+
+		if (this.senderSpec.getAutoUpdatePartitions() != null) {
+			producerBuilder.autoUpdatePartitions(this.senderSpec.getAutoUpdatePartitions());
+		}
+
+		if (this.senderSpec.getAutoUpdatePartitionsInterval() != null) {
+			producerBuilder.autoUpdatePartitionsInterval(
+					(int) (this.senderSpec.getAutoUpdatePartitionsInterval().toMillis() / 1000L), TimeUnit.SECONDS);
+		}
+
+		if (this.senderSpec.getMultiSchema() != null) {
+			producerBuilder.enableMultiSchema(this.senderSpec.getMultiSchema());
+		}
+
+		if (this.senderSpec.getAccessMode() != null) {
+			producerBuilder.accessMode(this.senderSpec.getAccessMode());
+		}
+
+		if (this.senderSpec.getLazyStartPartitionedProducers() != null) {
+			producerBuilder.enableLazyStartPartitionedProducers(this.senderSpec.getLazyStartPartitionedProducers());
+		}
+
+		if (this.senderSpec.getProperties() != null && !this.senderSpec.getProperties().isEmpty()) {
+			producerBuilder
+					.properties(Collections.unmodifiableMap(new LinkedHashMap<>(this.senderSpec.getProperties())));
+		}
+	}
+
+	@Override
+	public Mono<MessageId> sendMessage(Mono<MessageSpec<T>> messageSpec) {
+		return createReactiveProducerAdapter()
+				.usingProducer((producer) -> messageSpec.flatMap((m) -> createMessageMono(m, producer)));
+	}
+
+	private Mono<MessageId> createMessageMono(MessageSpec<T> messageSpec, Producer<T> producer) {
+		return PulsarFutureAdapter.adaptPulsarFuture(() -> {
+			TypedMessageBuilder<T> typedMessageBuilder = producer.newMessage();
+			((InternalMessageSpec<T>) messageSpec).configure(typedMessageBuilder);
+			return typedMessageBuilder.sendAsync();
+		});
+	}
+
+	@Override
+	public Flux<MessageId> sendMessages(Flux<MessageSpec<T>> messageSpecs) {
+		return createReactiveProducerAdapter().usingProducerMany((producer) ->
+		// TODO: ensure that inner publishers are subscribed in order so that message
+		// order is retained
+		messageSpecs.flatMapSequential((messageSpec) -> createMessageMono(messageSpec, producer), this.maxConcurrency));
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageSenderBuilder.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageSenderBuilder.java
new file mode 100644
index 0000000..846ce4f
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdaptedReactiveMessageSenderBuilder.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.ImmutableReactiveMessageSenderSpec;
+import org.apache.pulsar.reactive.client.api.MutableReactiveMessageSenderSpec;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSender;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderSpec;
+import org.apache.pulsar.reactive.client.internal.api.InflightLimiter;
+import org.apache.pulsar.reactive.client.internal.api.PublisherTransformer;
+import reactor.core.scheduler.Schedulers;
+
+class AdaptedReactiveMessageSenderBuilder<T> implements ReactiveMessageSenderBuilder<T> {
+
+	private static final int MAX_CONCURRENCY_LOWER_BOUND = 32;
+
+	private static final int MAX_CONCURRENCY_UPPER_BOUND = 256;
+
+	private final Schema<T> schema;
+
+	private final ReactiveProducerAdapterFactory reactiveProducerAdapterFactory;
+
+	private final MutableReactiveMessageSenderSpec senderSpec = new MutableReactiveMessageSenderSpec();
+
+	private ReactiveMessageSenderCache producerCache;
+
+	private int maxInflight = -1;
+
+	private int maxConcurrentSenderSubscriptions = InflightLimiter.DEFAULT_MAX_PENDING_SUBSCRIPTIONS;
+
+	private Supplier<PublisherTransformer> producerActionTransformer = PublisherTransformer::identity;
+
+	AdaptedReactiveMessageSenderBuilder(Schema<T> schema,
+			ReactiveProducerAdapterFactory reactiveProducerAdapterFactory) {
+		this.schema = schema;
+		this.reactiveProducerAdapterFactory = reactiveProducerAdapterFactory;
+	}
+
+	@Override
+	public ReactiveMessageSenderBuilder<T> cache(ReactiveMessageSenderCache producerCache) {
+		this.producerCache = producerCache;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessageSenderSpec toImmutableSpec() {
+		return new ImmutableReactiveMessageSenderSpec(this.senderSpec);
+	}
+
+	@Override
+	public MutableReactiveMessageSenderSpec getMutableSpec() {
+		return this.senderSpec;
+	}
+
+	@Override
+	public ReactiveMessageSenderBuilder<T> maxInflight(int maxInflight) {
+		this.maxInflight = maxInflight;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessageSenderBuilder<T> maxConcurrentSenderSubscriptions(int maxConcurrentSenderSubscriptions) {
+		this.maxConcurrentSenderSubscriptions = maxConcurrentSenderSubscriptions;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessageSender<T> build() {
+		if (this.maxInflight > 0) {
+			this.producerActionTransformer = () -> new InflightLimiter(this.maxInflight,
+					Math.max(this.maxInflight / 2, 1), Schedulers.single(), this.maxConcurrentSenderSubscriptions);
+		}
+		return new AdaptedReactiveMessageSender<>(this.schema, this.senderSpec, resolveMaxConcurrency(),
+				this.reactiveProducerAdapterFactory, (ProducerCache) this.producerCache,
+				this.producerActionTransformer);
+	}
+
+	private int resolveMaxConcurrency() {
+		return Math.min(Math.max(MAX_CONCURRENCY_LOWER_BOUND, this.maxInflight / 2), MAX_CONCURRENCY_UPPER_BOUND);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdapterImplementationFactory.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdapterImplementationFactory.java
new file mode 100644
index 0000000..5c65b43
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/AdapterImplementationFactory.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.Iterator;
+import java.util.ServiceLoader;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProviderFactory;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+import reactor.core.publisher.Mono;
+
+public final class AdapterImplementationFactory {
+
+	private AdapterImplementationFactory() {
+
+	}
+
+	private static final ProducerCacheProviderFactory PRODUCER_CACHE_PROVIDER_FACTORY;
+
+	static {
+		Iterator<ProducerCacheProviderFactory> iterator = ServiceLoader.load(ProducerCacheProviderFactory.class)
+				.iterator();
+		if (iterator.hasNext()) {
+			PRODUCER_CACHE_PROVIDER_FACTORY = iterator.next();
+		}
+		else {
+			PRODUCER_CACHE_PROVIDER_FACTORY = null;
+		}
+	}
+
+	public static ReactivePulsarClient createReactivePulsarClient(Supplier<PulsarClient> pulsarClientSupplier) {
+		return new ReactivePulsarResourceAdapterPulsarClient(new ReactivePulsarResourceAdapter(pulsarClientSupplier));
+	}
+
+	public static <T> Mono<T> adaptPulsarFuture(Supplier<? extends CompletableFuture<T>> futureSupplier) {
+		return PulsarFutureAdapter.adaptPulsarFuture(futureSupplier);
+	}
+
+	public static ReactiveMessageSenderCache createCache(ProducerCacheProvider producerCacheProvider) {
+		return new ProducerCache(producerCacheProvider);
+	}
+
+	public static ReactiveMessageSenderCache createCache() {
+		return new ProducerCache((PRODUCER_CACHE_PROVIDER_FACTORY != null) ? PRODUCER_CACHE_PROVIDER_FACTORY.get()
+				: new ConcurrentHashMapProducerCacheProvider());
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ConcurrentHashMapProducerCacheProvider.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ConcurrentHashMapProducerCacheProvider.java
new file mode 100644
index 0000000..a4fb5e1
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ConcurrentHashMapProducerCacheProvider.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+
+public class ConcurrentHashMapProducerCacheProvider implements ProducerCacheProvider {
+
+	private final ConcurrentHashMap<Object, CompletableFuture<Object>> cache;
+
+	public ConcurrentHashMapProducerCacheProvider() {
+		this.cache = new ConcurrentHashMap<>();
+	}
+
+	@Override
+	public <K, V> CompletableFuture<V> getOrCreateCachedEntry(K key,
+			Function<K, CompletableFuture<V>> createEntryFunction) {
+		return (CompletableFuture<V>) this.cache.computeIfAbsent(key,
+				(__) -> (CompletableFuture) createEntryFunction.apply(key));
+	}
+
+	@Override
+	public void close() throws Exception {
+		for (CompletableFuture<Object> future : this.cache.values()) {
+			future.thenAccept((value) -> {
+				if (value != null && value instanceof AutoCloseable) {
+					try {
+						((AutoCloseable) value).close();
+					}
+					catch (Exception ex) {
+						throw new RuntimeException(ex);
+					}
+				}
+			});
+			this.cache.clear();
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCache.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCache.java
new file mode 100644
index 0000000..a0a46b8
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCache.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache;
+import org.apache.pulsar.reactive.client.internal.api.PublisherTransformer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+class ProducerCache implements ReactiveMessageSenderCache {
+
+	private static final Logger log = LoggerFactory.getLogger(ProducerCache.class);
+
+	private final ProducerCacheProvider cacheProvider;
+
+	ProducerCache(ProducerCacheProvider cacheProvider) {
+		this.cacheProvider = cacheProvider;
+	}
+
+	static <T> CompletableFuture<ProducerCacheEntry> createCacheEntry(Mono<Producer<T>> producerMono,
+			Supplier<PublisherTransformer> producerActionTransformer) {
+		return producerMono.map((producer) -> new ProducerCacheEntry(producer, producerActionTransformer)).toFuture();
+	}
+
+	private <T> Mono<ProducerCacheEntry> getProducerCacheEntry(final ProducerCacheKey cacheKey,
+			Mono<Producer<T>> producerMono, Supplier<PublisherTransformer> producerActionTransformer) {
+		return AdapterImplementationFactory
+				.adaptPulsarFuture(() -> this.cacheProvider.getOrCreateCachedEntry(cacheKey,
+						(__) -> createCacheEntry(producerMono, producerActionTransformer)))
+				.flatMap((producerCacheEntry) -> producerCacheEntry.recreateIfClosed(producerMono));
+	}
+
+	<T, R> Mono<R> usingCachedProducer(ProducerCacheKey cacheKey, Mono<Producer<T>> producerMono,
+			Supplier<PublisherTransformer> producerActionTransformer,
+			Function<Producer<T>, Mono<R>> usingProducerAction) {
+		return Mono.usingWhen(this.leaseCacheEntry(cacheKey, producerMono, producerActionTransformer),
+				(producerCacheEntry) -> usingProducerAction.apply(producerCacheEntry.getProducer())
+						.as(producerCacheEntry::decorateProducerAction),
+				(producerCacheEntry) -> this.returnCacheEntry(producerCacheEntry));
+	}
+
+	private Mono<Object> returnCacheEntry(ProducerCacheEntry producerCacheEntry) {
+		return Mono.fromRunnable(producerCacheEntry::releaseLease);
+	}
+
+	private <T> Mono<ProducerCacheEntry> leaseCacheEntry(ProducerCacheKey cacheKey, Mono<Producer<T>> producerMono,
+			Supplier<PublisherTransformer> producerActionTransformer) {
+		return this.getProducerCacheEntry(cacheKey, producerMono, producerActionTransformer)
+				.doOnNext(ProducerCacheEntry::activateLease);
+	}
+
+	<T, R> Flux<R> usingCachedProducerMany(ProducerCacheKey cacheKey, Mono<Producer<T>> producerMono,
+			Supplier<PublisherTransformer> producerActionTransformer,
+			Function<Producer<T>, Flux<R>> usingProducerAction) {
+		return Flux.usingWhen(this.leaseCacheEntry(cacheKey, producerMono, producerActionTransformer),
+				(producerCacheEntry) -> usingProducerAction.apply(producerCacheEntry.getProducer())
+						.as(producerCacheEntry::decorateProducerAction),
+				(producerCacheEntry) -> this.returnCacheEntry(producerCacheEntry));
+	}
+
+	@Override
+	public void close() throws Exception {
+		if (this.cacheProvider instanceof AutoCloseable) {
+			this.cacheProvider.close();
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCacheEntry.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCacheEntry.java
new file mode 100644
index 0000000..12f4ced
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCacheEntry.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.reactive.client.internal.api.PublisherTransformer;
+import org.reactivestreams.Publisher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+class ProducerCacheEntry implements AutoCloseable {
+
+	private static final Logger log = LoggerFactory.getLogger(ProducerCacheEntry.class);
+
+	private final AtomicReference<Producer<?>> producer = new AtomicReference();
+
+	private final AtomicReference<Mono<? extends Producer<?>>> producerCreator = new AtomicReference();
+
+	private final AtomicInteger activeLeases = new AtomicInteger(0);
+
+	private final PublisherTransformer producerActionTransformer;
+
+	private volatile boolean removed;
+
+	ProducerCacheEntry(Producer<?> producer, Supplier<PublisherTransformer> producerActionTransformer) {
+		this.producer.set(producer);
+		this.producerCreator.set(Mono.fromSupplier(this.producer::get));
+		this.producerActionTransformer = (producerActionTransformer != null) ? producerActionTransformer.get()
+				: PublisherTransformer.identity();
+	}
+
+	private static void flushAndCloseProducerAsync(Producer<?> producer) {
+		producer.flushAsync().thenCompose((__) -> producer.closeAsync()).whenComplete((r, t) -> {
+			if (t != null) {
+				log.error("Error flushing and closing producer", t);
+			}
+		});
+	}
+
+	void activateLease() {
+		this.activeLeases.incrementAndGet();
+	}
+
+	void releaseLease() {
+		int currentLeases = this.activeLeases.decrementAndGet();
+		if (currentLeases == 0 && this.removed) {
+			cleanupResources();
+		}
+	}
+
+	int getActiveLeases() {
+		return this.activeLeases.get();
+	}
+
+	<T> Producer<T> getProducer() {
+		return (Producer<T>) this.producer.get();
+	}
+
+	<T> Mono<ProducerCacheEntry> recreateIfClosed(Mono<Producer<T>> producerMono) {
+		return Mono.defer(() -> {
+			Producer<?> p = this.producer.get();
+			if (p != null) {
+				if (p.isConnected()) {
+					return Mono.just(this);
+				}
+				else {
+					Mono<? extends Producer<?>> previousUpdater = this.producerCreator.get();
+					if (this.producerCreator.compareAndSet(previousUpdater, createCachedProducerMono(producerMono))) {
+						this.producer.compareAndSet(p, null);
+						flushAndCloseProducerAsync(p);
+					}
+				}
+			}
+			return Mono.defer(() -> this.producerCreator.get()).filter(Producer::isConnected)
+					.repeatWhenEmpty(5, (flux) -> flux.delayElements(Duration.ofSeconds(1))).thenReturn(this);
+		});
+	}
+
+	private <T> Mono<Producer<T>> createCachedProducerMono(Mono<Producer<T>> producerMono) {
+		return producerMono.doOnNext((newProducer) -> {
+			log.info("Replaced closed producer for topic {}", newProducer.getTopic());
+			this.producer.set(newProducer);
+		}).cache();
+	}
+
+	@Override
+	public void close() {
+		this.removed = true;
+		if (this.activeLeases.get() == 0) {
+			cleanupResources();
+		}
+	}
+
+	private void cleanupResources() {
+		try {
+			closeProducer();
+		}
+		finally {
+			this.producerActionTransformer.dispose();
+		}
+	}
+
+	private void closeProducer() {
+		Producer<?> p = this.producer.get();
+		if (p != null && this.producer.compareAndSet(p, null)) {
+			log.info("Closed producer {} for topic {}", p.getProducerName(), p.getTopic());
+			flushAndCloseProducerAsync(p);
+		}
+	}
+
+	<R> Publisher<? extends R> decorateProducerAction(Flux<R> source) {
+		return this.producerActionTransformer.transform(source);
+	}
+
+	<R> Mono<? extends R> decorateProducerAction(Mono<R> source) {
+		return Mono.from(this.producerActionTransformer.transform(source));
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCacheKey.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCacheKey.java
new file mode 100644
index 0000000..4d3686d
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ProducerCacheKey.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.Objects;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.client.impl.conf.ProducerConfigurationData;
+
+final class ProducerCacheKey {
+
+	private final PulsarClient pulsarClient;
+
+	private final ProducerConfigurationData producerConfigurationData;
+
+	private final Schema<?> schema;
+
+	ProducerCacheKey(final PulsarClient pulsarClient, final ProducerConfigurationData producerConfigurationData,
+			final Schema<?> schema) {
+		this.pulsarClient = pulsarClient;
+		this.producerConfigurationData = producerConfigurationData;
+		this.schema = schema;
+	}
+
+	String getTopicName() {
+		return (this.producerConfigurationData != null) ? this.producerConfigurationData.getTopicName() : null;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (this == o) {
+			return true;
+		}
+		if (o == null || getClass() != o.getClass()) {
+			return false;
+		}
+		ProducerCacheKey that = (ProducerCacheKey) o;
+		return (Objects.equals(this.pulsarClient, that.pulsarClient)
+				&& Objects.equals(this.producerConfigurationData, that.producerConfigurationData)
+				&& Objects.equals(this.schema, that.schema));
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.pulsarClient, this.producerConfigurationData, this.schema);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/PulsarFutureAdapter.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/PulsarFutureAdapter.java
new file mode 100644
index 0000000..f6d89d8
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/PulsarFutureAdapter.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.PulsarClientException;
+import reactor.core.publisher.Mono;
+
+/**
+ * Stateful adapter from CompletableFuture to Mono which keeps a reference to the original
+ * future so that it can be cancelled. Cancellation is necessary for some cases to release
+ * resources.
+ *
+ * There's additional logic to ignore Pulsar client's
+ * {@link org.apache.pulsar.client.api.PulsarClientException.AlreadyClosedException} when
+ * the Mono has been cancelled. This is to reduce unnecessary exceptions in logs.
+ *
+ * @author Lari Hotari
+ */
+final class PulsarFutureAdapter {
+
+	private volatile boolean cancelled;
+
+	private volatile CompletableFuture<?> futureReference;
+
+	private PulsarFutureAdapter() {
+	}
+
+	static <T> Mono<T> adaptPulsarFuture(Supplier<? extends CompletableFuture<T>> futureSupplier) {
+		return Mono.defer(() -> new PulsarFutureAdapter().toMono(futureSupplier));
+	}
+
+	private static void handleException(boolean cancelled, Throwable e) {
+		if (cancelled) {
+			rethrowIfRelevantException(e);
+		}
+		else {
+			sneakyThrow(e);
+		}
+	}
+
+	private static boolean isAlreadyClosedCause(Throwable e) {
+		return (e instanceof PulsarClientException.AlreadyClosedException
+				|| e.getCause() instanceof PulsarClientException.AlreadyClosedException);
+	}
+
+	private static void rethrowIfRelevantException(Throwable e) {
+		if (!isAlreadyClosedCause(e) && !(e instanceof CancellationException)) {
+			sneakyThrow(e);
+		}
+	}
+
+	private static <E extends Throwable> void sneakyThrow(Throwable e) throws E {
+		throw (E) e;
+	}
+
+	<T> Mono<? extends T> toMono(Supplier<? extends CompletableFuture<T>> futureSupplier) {
+		return Mono.fromFuture(() -> createFuture(futureSupplier)).doOnCancel(this::doOnCancel);
+	}
+
+	private <T> CompletableFuture<T> createFuture(Supplier<? extends CompletableFuture<T>> futureSupplier) {
+		try {
+			CompletableFuture<T> future = futureSupplier.get();
+			this.futureReference = future;
+			return future.exceptionally((ex) -> {
+				handleException(this.cancelled, ex);
+				return null;
+			});
+		}
+		catch (Exception ex) {
+			handleException(this.cancelled, ex);
+			return CompletableFuture.completedFuture(null);
+		}
+	}
+
+	private void doOnCancel() {
+		this.cancelled = true;
+		CompletableFuture<?> future = this.futureReference;
+		if (future != null) {
+			future.cancel(false);
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveConsumerAdapter.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveConsumerAdapter.java
new file mode 100644
index 0000000..7b59233
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveConsumerAdapter.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+class ReactiveConsumerAdapter<T> {
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	private final Function<PulsarClient, ConsumerBuilder<T>> consumerBuilderFactory;
+
+	private final Logger LOG = LoggerFactory.getLogger(ReactiveConsumerAdapter.class);
+
+	ReactiveConsumerAdapter(Supplier<PulsarClient> pulsarClientSupplier,
+			Function<PulsarClient, ConsumerBuilder<T>> consumerBuilderFactory) {
+		this.pulsarClientSupplier = pulsarClientSupplier;
+		this.consumerBuilderFactory = consumerBuilderFactory;
+	}
+
+	private Mono<Consumer<T>> createConsumerMono() {
+		return AdapterImplementationFactory.adaptPulsarFuture(
+				() -> this.consumerBuilderFactory.apply(this.pulsarClientSupplier.get()).subscribeAsync());
+	}
+
+	private Mono<Void> closeConsumer(Consumer<?> consumer) {
+		return Mono.fromFuture(consumer::closeAsync).doOnSuccess((__) -> this.LOG.info("Consumer closed {}", consumer));
+	}
+
+	<R> Mono<R> usingConsumer(Function<Consumer<T>, Mono<R>> usingConsumerAction) {
+		return Mono.usingWhen(createConsumerMono(), usingConsumerAction, this::closeConsumer);
+	}
+
+	<R> Flux<R> usingConsumerMany(Function<Consumer<T>, Flux<R>> usingConsumerAction) {
+		return Flux.usingWhen(createConsumerMono(), usingConsumerAction, this::closeConsumer);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveConsumerAdapterFactory.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveConsumerAdapterFactory.java
new file mode 100644
index 0000000..5f45ccd
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveConsumerAdapterFactory.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.ConsumerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+
+class ReactiveConsumerAdapterFactory {
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	ReactiveConsumerAdapterFactory(Supplier<PulsarClient> pulsarClientSupplier) {
+		this.pulsarClientSupplier = pulsarClientSupplier;
+	}
+
+	<T> ReactiveConsumerAdapter<T> create(Function<PulsarClient, ConsumerBuilder<T>> consumerBuilderFactory) {
+		return new ReactiveConsumerAdapter<T>(this.pulsarClientSupplier, consumerBuilderFactory);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveProducerAdapter.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveProducerAdapter.java
new file mode 100644
index 0000000..d3e8f31
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveProducerAdapter.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.impl.ProducerBuilderImpl;
+import org.apache.pulsar.reactive.client.internal.api.PublisherTransformer;
+import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
+
+class ReactiveProducerAdapter<T> {
+
+	private final ProducerCache producerCache;
+
+	private final Function<PulsarClient, ProducerBuilder<T>> producerBuilderFactory;
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	private final Supplier<PublisherTransformer> producerActionTransformer;
+
+	ReactiveProducerAdapter(Supplier<PulsarClient> pulsarClientSupplier,
+			Function<PulsarClient, ProducerBuilder<T>> producerBuilderFactory, ProducerCache producerCache,
+			Supplier<PublisherTransformer> producerActionTransformer) {
+		this.pulsarClientSupplier = pulsarClientSupplier;
+		this.producerBuilderFactory = producerBuilderFactory;
+		this.producerCache = producerCache;
+		this.producerActionTransformer = producerActionTransformer;
+	}
+
+	private Mono<Producer<T>> createProducerMono() {
+		return AdapterImplementationFactory.adaptPulsarFuture(
+				() -> this.producerBuilderFactory.apply(this.pulsarClientSupplier.get()).createAsync());
+	}
+
+	private Mono<Tuple2<ProducerCacheKey, Mono<Producer<T>>>> createCachedProducerKeyAndMono() {
+		return Mono.fromCallable(() -> {
+			PulsarClient pulsarClient = this.pulsarClientSupplier.get();
+			ProducerBuilderImpl<T> producerBuilder = (ProducerBuilderImpl<T>) this.producerBuilderFactory
+					.apply(pulsarClient);
+			ProducerCacheKey cacheKey = new ProducerCacheKey(pulsarClient, producerBuilder.getConf().clone(),
+					producerBuilder.getSchema());
+			return Tuples.of(cacheKey, AdapterImplementationFactory.adaptPulsarFuture(producerBuilder::createAsync));
+		});
+	}
+
+	private Mono<Void> closeProducer(Producer<?> producer) {
+		return AdapterImplementationFactory.adaptPulsarFuture(producer::closeAsync);
+	}
+
+	<R> Mono<R> usingProducer(Function<Producer<T>, Mono<R>> usingProducerAction) {
+		if (this.producerCache != null) {
+			return usingCachedProducer(usingProducerAction);
+		}
+		else {
+			return usingUncachedProducer(usingProducerAction);
+		}
+	}
+
+	private <R> Mono<R> usingUncachedProducer(Function<Producer<T>, Mono<R>> usingProducerAction) {
+		return Mono.usingWhen(createProducerMono(),
+				(producer) -> Mono.using(() -> this.producerActionTransformer.get(),
+						(transformer) -> usingProducerAction.apply(producer)
+								.as((mono) -> Mono.from(transformer.transform(mono))),
+						Disposable::dispose),
+				this::closeProducer);
+	}
+
+	private <R> Mono<R> usingCachedProducer(Function<Producer<T>, Mono<R>> usingProducerAction) {
+		return createCachedProducerKeyAndMono().flatMap((keyAndProducerMono) -> {
+			ProducerCacheKey cacheKey = keyAndProducerMono.getT1();
+			Mono<Producer<T>> producerMono = keyAndProducerMono.getT2();
+			return this.producerCache.usingCachedProducer(cacheKey, producerMono, this.producerActionTransformer,
+					usingProducerAction);
+		});
+	}
+
+	<R> Flux<R> usingProducerMany(Function<Producer<T>, Flux<R>> usingProducerAction) {
+		if (this.producerCache != null) {
+			return usingCachedProducerMany(usingProducerAction);
+		}
+		else {
+			return usingUncachedProducerMany(usingProducerAction);
+		}
+	}
+
+	private <R> Flux<R> usingUncachedProducerMany(Function<Producer<T>, Flux<R>> usingProducerAction) {
+		return Flux.usingWhen(createProducerMono(), (producer) -> Flux.using(() -> this.producerActionTransformer.get(),
+				(transformer) -> usingProducerAction.apply(producer).as(transformer::transform), Disposable::dispose),
+				this::closeProducer);
+	}
+
+	private <R> Flux<R> usingCachedProducerMany(Function<Producer<T>, Flux<R>> usingProducerAction) {
+		return createCachedProducerKeyAndMono().flatMapMany((keyAndProducerMono) -> {
+			ProducerCacheKey cacheKey = keyAndProducerMono.getT1();
+			Mono<Producer<T>> producerMono = keyAndProducerMono.getT2();
+			return this.producerCache.usingCachedProducerMany(cacheKey, producerMono, this.producerActionTransformer,
+					usingProducerAction);
+		});
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveProducerAdapterFactory.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveProducerAdapterFactory.java
new file mode 100644
index 0000000..ff33d92
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveProducerAdapterFactory.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.ProducerBuilder;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.reactive.client.internal.api.PublisherTransformer;
+
+class ReactiveProducerAdapterFactory {
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	ReactiveProducerAdapterFactory(Supplier<PulsarClient> pulsarClientSupplier) {
+		this.pulsarClientSupplier = pulsarClientSupplier;
+	}
+
+	<T> ReactiveProducerAdapter<T> create(Function<PulsarClient, ProducerBuilder<T>> producerBuilderFactory,
+			ProducerCache producerCache, Supplier<PublisherTransformer> producerActionTransformer) {
+		return new ReactiveProducerAdapter<>(this.pulsarClientSupplier, producerBuilderFactory, producerCache,
+				(producerActionTransformer != null) ? producerActionTransformer : PublisherTransformer::identity);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactivePulsarResourceAdapter.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactivePulsarResourceAdapter.java
new file mode 100644
index 0000000..f3c1f5f
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactivePulsarResourceAdapter.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.PulsarClient;
+
+class ReactivePulsarResourceAdapter {
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	ReactivePulsarResourceAdapter(Supplier<PulsarClient> pulsarClientSupplier) {
+		this.pulsarClientSupplier = cachedSupplier(pulsarClientSupplier);
+	}
+
+	private static <T> Supplier<T> cachedSupplier(Supplier<T> supplier) {
+		return new Supplier<T>() {
+			T cachedValue;
+
+			@Override
+			public synchronized T get() {
+				if (this.cachedValue == null) {
+					this.cachedValue = supplier.get();
+				}
+				return this.cachedValue;
+			}
+		};
+	}
+
+	static ReactivePulsarResourceAdapter create(Supplier<PulsarClient> pulsarClientSupplier) {
+		return new ReactivePulsarResourceAdapter(pulsarClientSupplier);
+	}
+
+	static ReactivePulsarResourceAdapter create(PulsarClient pulsarClient) {
+		return create(() -> pulsarClient);
+	}
+
+	ReactiveProducerAdapterFactory producer() {
+		return new ReactiveProducerAdapterFactory(this.pulsarClientSupplier);
+	}
+
+	ReactiveConsumerAdapterFactory consumer() {
+		return new ReactiveConsumerAdapterFactory(this.pulsarClientSupplier);
+	}
+
+	ReactiveReaderAdapterFactory reader() {
+		return new ReactiveReaderAdapterFactory(this.pulsarClientSupplier);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactivePulsarResourceAdapterPulsarClient.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactivePulsarResourceAdapterPulsarClient.java
new file mode 100644
index 0000000..9fe9b27
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactivePulsarResourceAdapterPulsarClient.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder;
+import org.apache.pulsar.reactive.client.api.ReactivePulsarClient;
+
+class ReactivePulsarResourceAdapterPulsarClient implements ReactivePulsarClient {
+
+	private final ReactivePulsarResourceAdapter reactivePulsarResourceAdapter;
+
+	ReactivePulsarResourceAdapterPulsarClient(ReactivePulsarResourceAdapter reactivePulsarResourceAdapter) {
+		this.reactivePulsarResourceAdapter = reactivePulsarResourceAdapter;
+	}
+
+	@Override
+	public <T> ReactiveMessageReaderBuilder<T> messageReader(Schema<T> schema) {
+		return new AdaptedReactiveMessageReaderBuilder<>(schema, this.reactivePulsarResourceAdapter.reader());
+	}
+
+	@Override
+	public <T> ReactiveMessageSenderBuilder<T> messageSender(Schema<T> schema) {
+		return new AdaptedReactiveMessageSenderBuilder<>(schema, this.reactivePulsarResourceAdapter.producer());
+	}
+
+	@Override
+	public <T> ReactiveMessageConsumerBuilder<T> messageConsumer(Schema<T> schema) {
+		return new AdaptedReactiveMessageConsumerBuilder<>(schema, this.reactivePulsarResourceAdapter.consumer());
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveReaderAdapter.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveReaderAdapter.java
new file mode 100644
index 0000000..3a2e352
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveReaderAdapter.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Reader;
+import org.apache.pulsar.client.api.ReaderBuilder;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+class ReactiveReaderAdapter<T> {
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	private final Function<PulsarClient, ReaderBuilder<T>> readerBuilderFactory;
+
+	ReactiveReaderAdapter(Supplier<PulsarClient> pulsarClientSupplier,
+			Function<PulsarClient, ReaderBuilder<T>> readerBuilderFactory) {
+		this.pulsarClientSupplier = pulsarClientSupplier;
+		this.readerBuilderFactory = readerBuilderFactory;
+	}
+
+	private Mono<Reader<T>> createReaderMono() {
+		return AdapterImplementationFactory.adaptPulsarFuture(
+				() -> this.readerBuilderFactory.apply(this.pulsarClientSupplier.get()).createAsync());
+	}
+
+	private Mono<Void> closeReader(Reader<?> reader) {
+		return AdapterImplementationFactory.adaptPulsarFuture(reader::closeAsync);
+	}
+
+	<R> Mono<R> usingReader(Function<Reader<T>, Mono<R>> usingReaderAction) {
+		return Mono.usingWhen(createReaderMono(), usingReaderAction, this::closeReader);
+	}
+
+	<R> Flux<R> usingReaderMany(Function<Reader<T>, Flux<R>> usingReaderAction) {
+		return Flux.usingWhen(createReaderMono(), usingReaderAction, this::closeReader);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveReaderAdapterFactory.java b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveReaderAdapterFactory.java
new file mode 100644
index 0000000..c9dd6d1
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/java/org/apache/pulsar/reactive/client/internal/adapter/ReactiveReaderAdapterFactory.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.ReaderBuilder;
+
+class ReactiveReaderAdapterFactory {
+
+	private final Supplier<PulsarClient> pulsarClientSupplier;
+
+	ReactiveReaderAdapterFactory(Supplier<PulsarClient> pulsarClientSupplier) {
+		this.pulsarClientSupplier = pulsarClientSupplier;
+	}
+
+	<T> ReactiveReaderAdapter<T> create(Function<PulsarClient, ReaderBuilder<T>> readerBuilderFactory) {
+		return new ReactiveReaderAdapter<T>(this.pulsarClientSupplier, readerBuilderFactory);
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/main/resources/META-INF/services/org.apache.pulsar.reactive.client.api.MessageGroupingFunction b/pulsar-client-reactive-adapter/src/main/resources/META-INF/services/org.apache.pulsar.reactive.client.api.MessageGroupingFunction
new file mode 100644
index 0000000..bf0250b
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/main/resources/META-INF/services/org.apache.pulsar.reactive.client.api.MessageGroupingFunction
@@ -0,0 +1 @@
+org.apache.pulsar.reactive.client.adapter.DefaultMessageGroupingFunction
\ No newline at end of file
diff --git a/pulsar-client-reactive-adapter/src/test/java/org/apache/pulsar/reactive/client/internal/adapter/InflightLimiterTest.java b/pulsar-client-reactive-adapter/src/test/java/org/apache/pulsar/reactive/client/internal/adapter/InflightLimiterTest.java
new file mode 100644
index 0000000..5d881f7
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/test/java/org/apache/pulsar/reactive/client/internal/adapter/InflightLimiterTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.adapter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.pulsar.reactive.client.internal.api.InflightLimiter;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.scheduler.Schedulers;
+import reactor.test.StepVerifier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InflightLimiterTest {
+
+	@Test
+	void shouldLimitInflight() {
+		List<Integer> values = Collections.synchronizedList(new ArrayList<>());
+		InflightLimiter inflightLimiter = new InflightLimiter(48, 24, Schedulers.single(),
+				InflightLimiter.DEFAULT_MAX_PENDING_SUBSCRIPTIONS);
+		Flux.merge(Arrays.asList(
+				Flux.range(1, 100).publishOn(Schedulers.parallel()).log().as(inflightLimiter::createOperator),
+				Flux.range(101, 100).publishOn(Schedulers.parallel()).log().as(inflightLimiter::createOperator),
+				Flux.range(201, 100).publishOn(Schedulers.parallel()).log().as(inflightLimiter::createOperator)))
+				.as(StepVerifier::create).expectSubscription().recordWith(() -> values).expectNextCount(300)
+				.expectComplete().verify();
+		assertThat(values)
+				.containsExactlyInAnyOrderElementsOf(IntStream.range(1, 301).boxed().collect(Collectors.toList()));
+		// verify "fairness"
+		// TODO: this is flaky, fix it
+		// verifyFairness(values);
+	}
+
+	private void verifyFairness(List<Integer> values) {
+		int previousValue = 1;
+		for (int i = 0; i < values.size(); i++) {
+			int value = values.get(i) % 100;
+			if (value == 0) {
+				value = 100;
+			}
+			assertThat(Math.abs(previousValue - value)).as("value %d at index %d", values.get(i), i)
+					.isLessThanOrEqualTo(48);
+			previousValue = value;
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-adapter/src/test/resources/log4j2-test.xml b/pulsar-client-reactive-adapter/src/test/resources/log4j2-test.xml
new file mode 100644
index 0000000..2171228
--- /dev/null
+++ b/pulsar-client-reactive-adapter/src/test/resources/log4j2-test.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Copyright 2022 the original author or authors.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         https://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+
+<Configuration status="WARN">
+	<Appenders>
+		<Console name="STDOUT" target="SYSTEM_OUT">
+			<PatternLayout pattern="[%d] [%t] [%c] %p %m%n"/>
+		</Console>
+	</Appenders>
+	<Loggers>
+		<Logger name="reactor" level="info"/>
+		<Logger name="com.github.lhotari" level="trace"/>
+		<Root level="off">
+			<AppenderRef ref="STDOUT"/>
+		</Root>
+	</Loggers>
+</Configuration>
diff --git a/pulsar-client-reactive-api/build.gradle b/pulsar-client-reactive-api/build.gradle
new file mode 100644
index 0000000..f22b224
--- /dev/null
+++ b/pulsar-client-reactive-api/build.gradle
@@ -0,0 +1,14 @@
+plugins {
+	id 'pulsar-client-reactive.codestyle-conventions'
+	id 'pulsar-client-reactive.library-conventions'
+	id 'pulsar-client-reactive.integration-test-conventions'
+}
+
+dependencies {
+	api libs.reactor.core
+	api libs.pulsar.client.api
+	api libs.slf4j.api
+	implementation libs.jctools.core
+}
+
+description = "Reactive Java client for Apache Pulsar"
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/EndOfStreamAction.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/EndOfStreamAction.java
new file mode 100644
index 0000000..09ccd3d
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/EndOfStreamAction.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+public enum EndOfStreamAction {
+
+	/** Complete at end of stream. */
+	COMPLETE,
+	/** Continue polling for new messages when end of stream is reached. */
+	POLL
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageConsumerSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageConsumerSpec.java
new file mode 100644
index 0000000..96da0c3
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageConsumerSpec.java
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.KeySharedPolicy;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import reactor.core.scheduler.Scheduler;
+
+public class ImmutableReactiveMessageConsumerSpec implements ReactiveMessageConsumerSpec {
+
+	private final List<String> topicNames;
+
+	private final Pattern topicsPattern;
+
+	private final RegexSubscriptionMode topicsPatternSubscriptionMode;
+
+	private final Duration topicsPatternAutoDiscoveryPeriod;
+
+	private final String subscriptionName;
+
+	private final SubscriptionMode subscriptionMode;
+
+	private final SubscriptionType subscriptionType;
+
+	private final KeySharedPolicy keySharedPolicy;
+
+	private final Boolean replicateSubscriptionState;
+
+	private final Map<String, String> subscriptionProperties;
+
+	private final String consumerName;
+
+	private final Map<String, String> properties;
+
+	private final Integer priorityLevel;
+
+	private final Boolean readCompacted;
+
+	private final Boolean batchIndexAckEnabled;
+
+	private final Duration ackTimeout;
+
+	private final Duration ackTimeoutTickTime;
+
+	private final Duration acknowledgementsGroupTime;
+
+	private final Boolean acknowledgeAsynchronously;
+
+	private final Scheduler acknowledgeScheduler;
+
+	private final Duration negativeAckRedeliveryDelay;
+
+	private final DeadLetterPolicy deadLetterPolicy;
+
+	private final Boolean retryLetterTopicEnable;
+
+	private final Integer receiverQueueSize;
+
+	private final Integer maxTotalReceiverQueueSizeAcrossPartitions;
+
+	private final Boolean autoUpdatePartitions;
+
+	private final Duration autoUpdatePartitionsInterval;
+
+	private final CryptoKeyReader cryptoKeyReader;
+
+	private final ConsumerCryptoFailureAction cryptoFailureAction;
+
+	private final Integer maxPendingChunkedMessage;
+
+	private final Boolean autoAckOldestChunkedMessageOnQueueFull;
+
+	private final Duration expireTimeOfIncompleteChunkedMessage;
+
+	public ImmutableReactiveMessageConsumerSpec(ReactiveMessageConsumerSpec consumerSpec) {
+		this.topicNames = (consumerSpec.getTopicNames() != null && !consumerSpec.getTopicNames().isEmpty())
+				? Collections.unmodifiableList(new ArrayList<>(consumerSpec.getTopicNames())) : null;
+
+		this.topicsPattern = consumerSpec.getTopicsPattern();
+
+		this.topicsPatternSubscriptionMode = consumerSpec.getTopicsPatternSubscriptionMode();
+
+		this.topicsPatternAutoDiscoveryPeriod = consumerSpec.getTopicsPatternAutoDiscoveryPeriod();
+
+		this.subscriptionName = consumerSpec.getSubscriptionName();
+
+		this.subscriptionMode = consumerSpec.getSubscriptionMode();
+
+		this.subscriptionType = consumerSpec.getSubscriptionType();
+
+		this.keySharedPolicy = consumerSpec.getKeySharedPolicy();
+
+		this.replicateSubscriptionState = consumerSpec.getReplicateSubscriptionState();
+
+		this.subscriptionProperties = (consumerSpec.getSubscriptionProperties() != null
+				&& !consumerSpec.getSubscriptionProperties().isEmpty())
+						? Collections.unmodifiableMap(new LinkedHashMap<>(consumerSpec.getSubscriptionProperties()))
+						: null;
+
+		this.consumerName = consumerSpec.getConsumerName();
+
+		this.properties = (consumerSpec.getProperties() != null && !consumerSpec.getProperties().isEmpty())
+				? Collections.unmodifiableMap(new LinkedHashMap<>(consumerSpec.getProperties())) : null;
+
+		this.priorityLevel = consumerSpec.getPriorityLevel();
+
+		this.readCompacted = consumerSpec.getReadCompacted();
+
+		this.batchIndexAckEnabled = consumerSpec.getBatchIndexAckEnabled();
+
+		this.ackTimeout = consumerSpec.getAckTimeout();
+
+		this.ackTimeoutTickTime = consumerSpec.getAckTimeoutTickTime();
+
+		this.acknowledgementsGroupTime = consumerSpec.getAcknowledgementsGroupTime();
+
+		this.acknowledgeAsynchronously = consumerSpec.getAcknowledgeAsynchronously();
+		this.acknowledgeScheduler = consumerSpec.getAcknowledgeScheduler();
+		this.negativeAckRedeliveryDelay = consumerSpec.getNegativeAckRedeliveryDelay();
+
+		this.deadLetterPolicy = consumerSpec.getDeadLetterPolicy();
+
+		this.retryLetterTopicEnable = consumerSpec.getRetryLetterTopicEnable();
+
+		this.receiverQueueSize = consumerSpec.getReceiverQueueSize();
+
+		this.maxTotalReceiverQueueSizeAcrossPartitions = consumerSpec.getMaxTotalReceiverQueueSizeAcrossPartitions();
+
+		this.autoUpdatePartitions = consumerSpec.getAutoUpdatePartitions();
+
+		this.autoUpdatePartitionsInterval = consumerSpec.getAutoUpdatePartitionsInterval();
+
+		this.cryptoKeyReader = consumerSpec.getCryptoKeyReader();
+
+		this.cryptoFailureAction = consumerSpec.getCryptoFailureAction();
+
+		this.maxPendingChunkedMessage = consumerSpec.getMaxPendingChunkedMessage();
+
+		this.autoAckOldestChunkedMessageOnQueueFull = consumerSpec.getAutoAckOldestChunkedMessageOnQueueFull();
+
+		this.expireTimeOfIncompleteChunkedMessage = consumerSpec.getExpireTimeOfIncompleteChunkedMessage();
+	}
+
+	public List<String> getTopicNames() {
+		return this.topicNames;
+	}
+
+	public Pattern getTopicsPattern() {
+		return this.topicsPattern;
+	}
+
+	public RegexSubscriptionMode getTopicsPatternSubscriptionMode() {
+		return this.topicsPatternSubscriptionMode;
+	}
+
+	public Duration getTopicsPatternAutoDiscoveryPeriod() {
+		return this.topicsPatternAutoDiscoveryPeriod;
+	}
+
+	public String getSubscriptionName() {
+		return this.subscriptionName;
+	}
+
+	public SubscriptionMode getSubscriptionMode() {
+		return this.subscriptionMode;
+	}
+
+	public SubscriptionType getSubscriptionType() {
+		return this.subscriptionType;
+	}
+
+	public KeySharedPolicy getKeySharedPolicy() {
+		return this.keySharedPolicy;
+	}
+
+	public Boolean getReplicateSubscriptionState() {
+		return this.replicateSubscriptionState;
+	}
+
+	public Map<String, String> getSubscriptionProperties() {
+		return this.subscriptionProperties;
+	}
+
+	public String getConsumerName() {
+		return this.consumerName;
+	}
+
+	public Map<String, String> getProperties() {
+		return this.properties;
+	}
+
+	public Integer getPriorityLevel() {
+		return this.priorityLevel;
+	}
+
+	public Boolean getReadCompacted() {
+		return this.readCompacted;
+	}
+
+	public Boolean getBatchIndexAckEnabled() {
+		return this.batchIndexAckEnabled;
+	}
+
+	public Duration getAckTimeout() {
+		return this.ackTimeout;
+	}
+
+	public Duration getAckTimeoutTickTime() {
+		return this.ackTimeoutTickTime;
+	}
+
+	public Duration getAcknowledgementsGroupTime() {
+		return this.acknowledgementsGroupTime;
+	}
+
+	public Boolean getAcknowledgeAsynchronously() {
+		return this.acknowledgeAsynchronously;
+	}
+
+	public Scheduler getAcknowledgeScheduler() {
+		return this.acknowledgeScheduler;
+	}
+
+	public Duration getNegativeAckRedeliveryDelay() {
+		return this.negativeAckRedeliveryDelay;
+	}
+
+	public DeadLetterPolicy getDeadLetterPolicy() {
+		return this.deadLetterPolicy;
+	}
+
+	public Boolean getRetryLetterTopicEnable() {
+		return this.retryLetterTopicEnable;
+	}
+
+	public Integer getReceiverQueueSize() {
+		return this.receiverQueueSize;
+	}
+
+	public Integer getMaxTotalReceiverQueueSizeAcrossPartitions() {
+		return this.maxTotalReceiverQueueSizeAcrossPartitions;
+	}
+
+	public Boolean getAutoUpdatePartitions() {
+		return this.autoUpdatePartitions;
+	}
+
+	public Duration getAutoUpdatePartitionsInterval() {
+		return this.autoUpdatePartitionsInterval;
+	}
+
+	public CryptoKeyReader getCryptoKeyReader() {
+		return this.cryptoKeyReader;
+	}
+
+	public ConsumerCryptoFailureAction getCryptoFailureAction() {
+		return this.cryptoFailureAction;
+	}
+
+	public Integer getMaxPendingChunkedMessage() {
+		return this.maxPendingChunkedMessage;
+	}
+
+	public Boolean getAutoAckOldestChunkedMessageOnQueueFull() {
+		return this.autoAckOldestChunkedMessageOnQueueFull;
+	}
+
+	public Duration getExpireTimeOfIncompleteChunkedMessage() {
+		return this.expireTimeOfIncompleteChunkedMessage;
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageReaderSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageReaderSpec.java
new file mode 100644
index 0000000..6d75992
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageReaderSpec.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.Range;
+
+public class ImmutableReactiveMessageReaderSpec implements ReactiveMessageReaderSpec {
+
+	private final List<String> topicNames;
+
+	private final String readerName;
+
+	private final String subscriptionName;
+
+	private final String generatedSubscriptionNamePrefix;
+
+	private final Integer receiverQueueSize;
+
+	private final Boolean readCompacted;
+
+	private final List<Range> keyHashRanges;
+
+	private final CryptoKeyReader cryptoKeyReader;
+
+	private final ConsumerCryptoFailureAction cryptoFailureAction;
+
+	public ImmutableReactiveMessageReaderSpec(ReactiveMessageReaderSpec readerSpec) {
+		this.topicNames = (readerSpec.getTopicNames() != null && !readerSpec.getTopicNames().isEmpty())
+				? Collections.unmodifiableList(new ArrayList<>(readerSpec.getTopicNames())) : null;
+		this.readerName = readerSpec.getReaderName();
+		this.subscriptionName = readerSpec.getSubscriptionName();
+		this.generatedSubscriptionNamePrefix = readerSpec.getGeneratedSubscriptionNamePrefix();
+		this.receiverQueueSize = readerSpec.getReceiverQueueSize();
+		this.readCompacted = readerSpec.getReadCompacted();
+		this.keyHashRanges = readerSpec.getKeyHashRanges();
+		this.cryptoKeyReader = readerSpec.getCryptoKeyReader();
+		this.cryptoFailureAction = readerSpec.getCryptoFailureAction();
+	}
+
+	@Override
+	public List<String> getTopicNames() {
+		return this.topicNames;
+	}
+
+	@Override
+	public String getReaderName() {
+		return this.readerName;
+	}
+
+	@Override
+	public String getSubscriptionName() {
+		return this.subscriptionName;
+	}
+
+	@Override
+	public String getGeneratedSubscriptionNamePrefix() {
+		return this.generatedSubscriptionNamePrefix;
+	}
+
+	@Override
+	public Integer getReceiverQueueSize() {
+		return this.receiverQueueSize;
+	}
+
+	@Override
+	public Boolean getReadCompacted() {
+		return this.readCompacted;
+	}
+
+	@Override
+	public List<Range> getKeyHashRanges() {
+		return this.keyHashRanges;
+	}
+
+	@Override
+	public CryptoKeyReader getCryptoKeyReader() {
+		return this.cryptoKeyReader;
+	}
+
+	@Override
+	public ConsumerCryptoFailureAction getCryptoFailureAction() {
+		return this.cryptoFailureAction;
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageSenderSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageSenderSpec.java
new file mode 100644
index 0000000..d70b847
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ImmutableReactiveMessageSenderSpec.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.pulsar.client.api.BatcherBuilder;
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRouter;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.ProducerCryptoFailureAction;
+
+public class ImmutableReactiveMessageSenderSpec implements ReactiveMessageSenderSpec {
+
+	private final String topicName;
+
+	private final String producerName;
+
+	private final Duration sendTimeout;
+
+	private final Integer maxPendingMessages;
+
+	private final Integer maxPendingMessagesAcrossPartitions;
+
+	private final MessageRoutingMode messageRoutingMode;
+
+	private final HashingScheme hashingScheme;
+
+	private final ProducerCryptoFailureAction cryptoFailureAction;
+
+	private final MessageRouter messageRouter;
+
+	private final Duration batchingMaxPublishDelay;
+
+	private final Integer roundRobinRouterBatchingPartitionSwitchFrequency;
+
+	private final Integer batchingMaxMessages;
+
+	private final Integer batchingMaxBytes;
+
+	private final Boolean batchingEnabled;
+
+	private final BatcherBuilder batcherBuilder;
+
+	private final Boolean chunkingEnabled;
+
+	private final CryptoKeyReader cryptoKeyReader;
+
+	private final Set<String> encryptionKeys;
+
+	private final CompressionType compressionType;
+
+	private final Long initialSequenceId;
+
+	private final Boolean autoUpdatePartitions;
+
+	private final Duration autoUpdatePartitionsInterval;
+
+	private final Boolean multiSchema;
+
+	private final ProducerAccessMode accessMode;
+
+	private final Boolean lazyStartPartitionedProducers;
+
+	private final Map<String, String> properties;
+
+	public ImmutableReactiveMessageSenderSpec(ReactiveMessageSenderSpec senderSpec) {
+		this.topicName = senderSpec.getTopicName();
+		this.producerName = senderSpec.getProducerName();
+		this.sendTimeout = senderSpec.getSendTimeout();
+		this.maxPendingMessages = senderSpec.getMaxPendingMessages();
+		this.maxPendingMessagesAcrossPartitions = senderSpec.getMaxPendingMessagesAcrossPartitions();
+		this.messageRoutingMode = senderSpec.getMessageRoutingMode();
+		this.hashingScheme = senderSpec.getHashingScheme();
+		this.cryptoFailureAction = senderSpec.getCryptoFailureAction();
+		this.messageRouter = senderSpec.getMessageRouter();
+		this.batchingMaxPublishDelay = senderSpec.getBatchingMaxPublishDelay();
+		this.roundRobinRouterBatchingPartitionSwitchFrequency = senderSpec
+				.getRoundRobinRouterBatchingPartitionSwitchFrequency();
+		this.batchingMaxMessages = senderSpec.getBatchingMaxMessages();
+		this.batchingMaxBytes = senderSpec.getBatchingMaxBytes();
+		this.batchingEnabled = senderSpec.getBatchingEnabled();
+		this.batcherBuilder = senderSpec.getBatcherBuilder();
+		this.chunkingEnabled = senderSpec.getChunkingEnabled();
+		this.cryptoKeyReader = senderSpec.getCryptoKeyReader();
+		this.encryptionKeys = (senderSpec.getEncryptionKeys() != null && !senderSpec.getEncryptionKeys().isEmpty())
+				? Collections.unmodifiableSet(new HashSet<>(senderSpec.getEncryptionKeys())) : null;
+
+		this.compressionType = senderSpec.getCompressionType();
+		this.initialSequenceId = senderSpec.getInitialSequenceId();
+		this.autoUpdatePartitions = senderSpec.getAutoUpdatePartitions();
+		this.autoUpdatePartitionsInterval = senderSpec.getAutoUpdatePartitionsInterval();
+		this.multiSchema = senderSpec.getMultiSchema();
+		this.accessMode = senderSpec.getAccessMode();
+		this.lazyStartPartitionedProducers = senderSpec.getLazyStartPartitionedProducers();
+		this.properties = (senderSpec.getProperties() != null && !senderSpec.getProperties().isEmpty())
+				? Collections.unmodifiableMap(new LinkedHashMap<>(senderSpec.getProperties())) : null;
+	}
+
+	@Override
+	public String getTopicName() {
+		return this.topicName;
+	}
+
+	@Override
+	public String getProducerName() {
+		return this.producerName;
+	}
+
+	@Override
+	public Duration getSendTimeout() {
+		return this.sendTimeout;
+	}
+
+	@Override
+	public Integer getMaxPendingMessages() {
+		return this.maxPendingMessages;
+	}
+
+	@Override
+	public Integer getMaxPendingMessagesAcrossPartitions() {
+		return this.maxPendingMessagesAcrossPartitions;
+	}
+
+	@Override
+	public MessageRoutingMode getMessageRoutingMode() {
+		return this.messageRoutingMode;
+	}
+
+	@Override
+	public HashingScheme getHashingScheme() {
+		return this.hashingScheme;
+	}
+
+	@Override
+	public ProducerCryptoFailureAction getCryptoFailureAction() {
+		return this.cryptoFailureAction;
+	}
+
+	@Override
+	public MessageRouter getMessageRouter() {
+		return this.messageRouter;
+	}
+
+	@Override
+	public Duration getBatchingMaxPublishDelay() {
+		return this.batchingMaxPublishDelay;
+	}
+
+	@Override
+	public Integer getRoundRobinRouterBatchingPartitionSwitchFrequency() {
+		return this.roundRobinRouterBatchingPartitionSwitchFrequency;
+	}
+
+	@Override
+	public Integer getBatchingMaxMessages() {
+		return this.batchingMaxMessages;
+	}
+
+	@Override
+	public Integer getBatchingMaxBytes() {
+		return this.batchingMaxBytes;
+	}
+
+	@Override
+	public Boolean getBatchingEnabled() {
+		return this.batchingEnabled;
+	}
+
+	@Override
+	public BatcherBuilder getBatcherBuilder() {
+		return this.batcherBuilder;
+	}
+
+	@Override
+	public Boolean getChunkingEnabled() {
+		return this.chunkingEnabled;
+	}
+
+	@Override
+	public CryptoKeyReader getCryptoKeyReader() {
+		return this.cryptoKeyReader;
+	}
+
+	@Override
+	public Set<String> getEncryptionKeys() {
+		return this.encryptionKeys;
+	}
+
+	@Override
+	public CompressionType getCompressionType() {
+		return this.compressionType;
+	}
+
+	@Override
+	public Long getInitialSequenceId() {
+		return this.initialSequenceId;
+	}
+
+	@Override
+	public Boolean getAutoUpdatePartitions() {
+		return this.autoUpdatePartitions;
+	}
+
+	@Override
+	public Duration getAutoUpdatePartitionsInterval() {
+		return this.autoUpdatePartitionsInterval;
+	}
+
+	@Override
+	public Boolean getMultiSchema() {
+		return this.multiSchema;
+	}
+
+	@Override
+	public ProducerAccessMode getAccessMode() {
+		return this.accessMode;
+	}
+
+	@Override
+	public Boolean getLazyStartPartitionedProducers() {
+		return this.lazyStartPartitionedProducers;
+	}
+
+	@Override
+	public Map<String, String> getProperties() {
+		return this.properties;
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/InstantStartAtSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/InstantStartAtSpec.java
new file mode 100644
index 0000000..c67338e
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/InstantStartAtSpec.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Instant;
+import java.util.Objects;
+
+public final class InstantStartAtSpec extends StartAtSpec {
+
+	private final Instant instant;
+
+	public InstantStartAtSpec(final Instant instant) {
+		this.instant = instant;
+	}
+
+	public Instant getInstant() {
+		return this.instant;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (this == o) {
+			return true;
+		}
+		if (o == null || getClass() != o.getClass()) {
+			return false;
+		}
+		InstantStartAtSpec that = (InstantStartAtSpec) o;
+		return Objects.equals(this.instant, that.instant);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.instant);
+	}
+
+	@Override
+	public String toString() {
+		return "InstantStartAtSpec{" + "instant=" + this.instant + '}';
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageGroupingFunction.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageGroupingFunction.java
new file mode 100644
index 0000000..38f2ec1
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageGroupingFunction.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import org.apache.pulsar.client.api.Message;
+
+public interface MessageGroupingFunction {
+
+	/**
+	 * Resolves a processing group for a message. This is used for implementing
+	 * key-ordered message processing with
+	 * {@link ReactiveMessagePipelineBuilder.ConcurrentOneByOneMessagePipelineBuilder}
+	 * @param message the Pulsar message
+	 * @param numberOfGroups maximum number of groups
+	 * @return processing group for the message, in the range of 0 to numberOfGroups,
+	 * exclusive
+	 */
+	int resolveProcessingGroup(Message<?> message, int numberOfGroups);
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageIdStartAtSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageIdStartAtSpec.java
new file mode 100644
index 0000000..2fae3a1
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageIdStartAtSpec.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.Objects;
+
+import org.apache.pulsar.client.api.MessageId;
+
+public final class MessageIdStartAtSpec extends StartAtSpec {
+
+	private final MessageId messageId;
+
+	private final boolean inclusive;
+
+	public MessageIdStartAtSpec(final MessageId messageId, final boolean inclusive) {
+		this.messageId = messageId;
+		this.inclusive = inclusive;
+	}
+
+	public MessageId getMessageId() {
+		return this.messageId;
+	}
+
+	public boolean isInclusive() {
+		return this.inclusive;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (this == o) {
+			return true;
+		}
+		if (o == null || getClass() != o.getClass()) {
+			return false;
+		}
+		MessageIdStartAtSpec that = (MessageIdStartAtSpec) o;
+		return (this.inclusive == that.inclusive && Objects.equals(this.messageId, that.messageId));
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(this.messageId, this.inclusive);
+	}
+
+	@Override
+	public String toString() {
+		return ("MessageIdStartAtSpec{" + "messageId=" + this.messageId + ", inclusive=" + this.inclusive + '}');
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageResult.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageResult.java
new file mode 100644
index 0000000..6b5fff2
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageResult.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.reactive.client.internal.api.ApiImplementationFactory;
+
+public interface MessageResult<T> {
+
+	static <T> MessageResult<T> acknowledge(MessageId messageId, T value) {
+		return ApiImplementationFactory.acknowledge(messageId, value);
+	}
+
+	static <T> MessageResult<T> negativeAcknowledge(MessageId messageId, T value) {
+		return ApiImplementationFactory.negativeAcknowledge(messageId, value);
+	}
+
+	static MessageResult<Void> acknowledge(MessageId messageId) {
+		return ApiImplementationFactory.acknowledge(messageId);
+	}
+
+	static MessageResult<Void> negativeAcknowledge(MessageId messageId) {
+		return ApiImplementationFactory.negativeAcknowledge(messageId);
+	}
+
+	static <V> MessageResult<Message<V>> acknowledgeAndReturn(Message<V> message) {
+		return acknowledge(message.getMessageId(), message);
+	}
+
+	boolean isAcknowledgeMessage();
+
+	MessageId getMessageId();
+
+	T getValue();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageSpec.java
new file mode 100644
index 0000000..260be90
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageSpec.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import org.apache.pulsar.reactive.client.internal.api.ApiImplementationFactory;
+
+public interface MessageSpec<T> {
+
+	static <T> MessageSpecBuilder<T> builder(T value) {
+		return ApiImplementationFactory.createMessageSpecBuilder(value);
+	}
+
+	static <T> MessageSpec<T> of(T value) {
+		return ApiImplementationFactory.createValueOnlyMessageSpec(value);
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageSpecBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageSpecBuilder.java
new file mode 100644
index 0000000..ebe05a7
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MessageSpecBuilder.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.SubscriptionType;
+
+public interface MessageSpecBuilder<T> {
+
+	/**
+	 * Sets the key of the message for routing policy.
+	 * @param key the partitioning key for the message
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> key(String key);
+
+	/**
+	 * Sets the bytes of the key of the message for routing policy. Internally the bytes
+	 * will be base64 encoded.
+	 * @param key routing key for message, in byte array form
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> keyBytes(byte[] key);
+
+	/**
+	 * Sets the ordering key of the message for message dispatch in
+	 * {@link SubscriptionType#Key_Shared} mode. Partition key Will be used if ordering
+	 * key not specified.
+	 * @param orderingKey the ordering key for the message
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> orderingKey(byte[] orderingKey);
+
+	/**
+	 * Set a domain object on the message.
+	 * @param value the domain object
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> value(T value);
+
+	/**
+	 * Sets a new property on a message.
+	 * @param name the name of the property
+	 * @param value the associated value
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> property(String name, String value);
+
+	/**
+	 * Add all the properties in the provided map.
+	 * @param properties properties to use
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> properties(Map<String, String> properties);
+
+	/**
+	 * Set the event time for a given message.
+	 *
+	 * <p>
+	 * Applications can retrieve the event time by calling {@link Message#getEventTime()}.
+	 *
+	 * <p>
+	 * Note: currently pulsar doesn't support event-time based index. so the subscribers
+	 * can't seek the messages by event time.
+	 * @param timestamp event timestamp (milliseconds since epoch)
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> eventTime(long timestamp);
+
+	/**
+	 * Specify a custom sequence id for the message being published.
+	 *
+	 * <p>
+	 * The sequence id can be used for deduplication purposes and it needs to follow these
+	 * rules:
+	 * <ol>
+	 * <li><code>sequenceId &gt;= 0</code>
+	 * <li>Sequence id for a message needs to be greater than sequence id for earlier
+	 * messages: <code>sequenceId(N+1) &gt; sequenceId(N)</code>
+	 * <li>It's not necessary for sequence ids to be consecutive. There can be holes
+	 * between messages. Eg. the <code>sequenceId</code> could represent an offset or a
+	 * cumulative size.
+	 * </ol>
+	 * @param sequenceId the sequence id to assign to the current message
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> sequenceId(long sequenceId);
+
+	/**
+	 * Override the geo-replication clusters for this message.
+	 * @param clusters the list of clusters.
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> replicationClusters(List<String> clusters);
+
+	/**
+	 * Disable geo-replication for this message.
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> disableReplication();
+
+	/**
+	 * Deliver the message only at or after the specified absolute timestamp.
+	 *
+	 * <p>
+	 * The timestamp is milliseconds and based on UTC (eg:
+	 * {@link System#currentTimeMillis()}.
+	 *
+	 * <p>
+	 * <b>Note</b>: messages are only delivered with delay when a consumer is consuming
+	 * through a {@link SubscriptionType#Shared} subscription. With other subscription
+	 * types, the messages will still be delivered immediately.
+	 * @param timestamp absolute timestamp indicating when the message should be delivered
+	 * to consumers
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> deliverAt(long timestamp);
+
+	/**
+	 * Request to deliver the message only after the specified relative delay.
+	 *
+	 * <p>
+	 * <b>Note</b>: messages are only delivered with delay when a consumer is consuming
+	 * through a {@link SubscriptionType#Shared} subscription. With other subscription
+	 * types, the messages will still be delivered immediately.
+	 * @param delay the amount of delay before the message will be delivered
+	 * @param unit the time unit for the delay
+	 * @return the message builder instance
+	 */
+	MessageSpecBuilder<T> deliverAfter(long delay, TimeUnit unit);
+
+	MessageSpec<T> build();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageConsumerSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageConsumerSpec.java
new file mode 100644
index 0000000..c96d371
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageConsumerSpec.java
@@ -0,0 +1,554 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.KeySharedPolicy;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import reactor.core.scheduler.Scheduler;
+
+public class MutableReactiveMessageConsumerSpec implements ReactiveMessageConsumerSpec {
+
+	private List<String> topicNames = new ArrayList<>();
+
+	private Pattern topicsPattern;
+
+	private RegexSubscriptionMode topicsPatternSubscriptionMode;
+
+	private Duration topicsPatternAutoDiscoveryPeriod;
+
+	private String subscriptionName;
+
+	private SubscriptionMode subscriptionMode;
+
+	private SubscriptionType subscriptionType;
+
+	private KeySharedPolicy keySharedPolicy;
+
+	private Boolean replicateSubscriptionState;
+
+	private Map<String, String> subscriptionProperties;
+
+	private String consumerName;
+
+	private Map<String, String> properties;
+
+	private Integer priorityLevel;
+
+	private Boolean readCompacted;
+
+	private Boolean batchIndexAckEnabled;
+
+	private Duration ackTimeout;
+
+	private Duration ackTimeoutTickTime;
+
+	private Duration acknowledgementsGroupTime;
+
+	private Boolean acknowledgeAsynchronously;
+
+	private Scheduler acknowledgeScheduler;
+
+	private Duration negativeAckRedeliveryDelay;
+
+	private DeadLetterPolicy deadLetterPolicy;
+
+	private Boolean retryLetterTopicEnable;
+
+	private Integer receiverQueueSize;
+
+	private Integer maxTotalReceiverQueueSizeAcrossPartitions;
+
+	private Boolean autoUpdatePartitions;
+
+	private Duration autoUpdatePartitionsInterval;
+
+	private CryptoKeyReader cryptoKeyReader;
+
+	private ConsumerCryptoFailureAction cryptoFailureAction;
+
+	private Integer maxPendingChunkedMessage;
+
+	private Boolean autoAckOldestChunkedMessageOnQueueFull;
+
+	private Duration expireTimeOfIncompleteChunkedMessage;
+
+	public MutableReactiveMessageConsumerSpec() {
+
+	}
+
+	public MutableReactiveMessageConsumerSpec(ReactiveMessageConsumerSpec consumerSpec) {
+		this.topicNames = (consumerSpec.getTopicNames() != null && !consumerSpec.getTopicNames().isEmpty())
+				? new ArrayList<>(consumerSpec.getTopicNames()) : new ArrayList<>();
+
+		this.topicsPattern = consumerSpec.getTopicsPattern();
+
+		this.topicsPatternSubscriptionMode = consumerSpec.getTopicsPatternSubscriptionMode();
+
+		this.topicsPatternAutoDiscoveryPeriod = consumerSpec.getTopicsPatternAutoDiscoveryPeriod();
+
+		this.subscriptionName = consumerSpec.getSubscriptionName();
+
+		this.subscriptionMode = consumerSpec.getSubscriptionMode();
+
+		this.subscriptionType = consumerSpec.getSubscriptionType();
+
+		this.keySharedPolicy = consumerSpec.getKeySharedPolicy();
+
+		this.replicateSubscriptionState = consumerSpec.getReplicateSubscriptionState();
+
+		this.subscriptionProperties = (consumerSpec.getSubscriptionProperties() != null
+				&& !consumerSpec.getSubscriptionProperties().isEmpty())
+						? new LinkedHashMap<>(consumerSpec.getSubscriptionProperties()) : null;
+
+		this.consumerName = consumerSpec.getConsumerName();
+
+		this.properties = (consumerSpec.getProperties() != null && !consumerSpec.getProperties().isEmpty())
+				? new LinkedHashMap<>(consumerSpec.getProperties()) : null;
+
+		this.priorityLevel = consumerSpec.getPriorityLevel();
+
+		this.readCompacted = consumerSpec.getReadCompacted();
+
+		this.batchIndexAckEnabled = consumerSpec.getBatchIndexAckEnabled();
+
+		this.ackTimeout = consumerSpec.getAckTimeout();
+
+		this.ackTimeoutTickTime = consumerSpec.getAckTimeoutTickTime();
+
+		this.acknowledgementsGroupTime = consumerSpec.getAcknowledgementsGroupTime();
+
+		this.acknowledgeAsynchronously = consumerSpec.getAcknowledgeAsynchronously();
+		this.acknowledgeScheduler = consumerSpec.getAcknowledgeScheduler();
+		this.negativeAckRedeliveryDelay = consumerSpec.getNegativeAckRedeliveryDelay();
+
+		this.deadLetterPolicy = consumerSpec.getDeadLetterPolicy();
+
+		this.retryLetterTopicEnable = consumerSpec.getRetryLetterTopicEnable();
+
+		this.receiverQueueSize = consumerSpec.getReceiverQueueSize();
+
+		this.maxTotalReceiverQueueSizeAcrossPartitions = consumerSpec.getMaxTotalReceiverQueueSizeAcrossPartitions();
+
+		this.autoUpdatePartitions = consumerSpec.getAutoUpdatePartitions();
+
+		this.autoUpdatePartitionsInterval = consumerSpec.getAutoUpdatePartitionsInterval();
+
+		this.cryptoKeyReader = consumerSpec.getCryptoKeyReader();
+
+		this.cryptoFailureAction = consumerSpec.getCryptoFailureAction();
+
+		this.maxPendingChunkedMessage = consumerSpec.getMaxPendingChunkedMessage();
+
+		this.autoAckOldestChunkedMessageOnQueueFull = consumerSpec.getAutoAckOldestChunkedMessageOnQueueFull();
+
+		this.expireTimeOfIncompleteChunkedMessage = consumerSpec.getExpireTimeOfIncompleteChunkedMessage();
+	}
+
+	@Override
+	public List<String> getTopicNames() {
+		return this.topicNames;
+	}
+
+	public void setTopicNames(List<String> topicNames) {
+		this.topicNames = topicNames;
+	}
+
+	@Override
+	public Pattern getTopicsPattern() {
+		return this.topicsPattern;
+	}
+
+	public void setTopicsPattern(Pattern topicsPattern) {
+		this.topicsPattern = topicsPattern;
+	}
+
+	@Override
+	public RegexSubscriptionMode getTopicsPatternSubscriptionMode() {
+		return this.topicsPatternSubscriptionMode;
+	}
+
+	public void setTopicsPatternSubscriptionMode(RegexSubscriptionMode topicsPatternSubscriptionMode) {
+		this.topicsPatternSubscriptionMode = topicsPatternSubscriptionMode;
+	}
+
+	@Override
+	public Duration getTopicsPatternAutoDiscoveryPeriod() {
+		return this.topicsPatternAutoDiscoveryPeriod;
+	}
+
+	public void setTopicsPatternAutoDiscoveryPeriod(Duration topicsPatternAutoDiscoveryPeriod) {
+		this.topicsPatternAutoDiscoveryPeriod = topicsPatternAutoDiscoveryPeriod;
+	}
+
+	@Override
+	public String getSubscriptionName() {
+		return this.subscriptionName;
+	}
+
+	public void setSubscriptionName(String subscriptionName) {
+		this.subscriptionName = subscriptionName;
+	}
+
+	@Override
+	public SubscriptionMode getSubscriptionMode() {
+		return this.subscriptionMode;
+	}
+
+	public void setSubscriptionMode(SubscriptionMode subscriptionMode) {
+		this.subscriptionMode = subscriptionMode;
+	}
+
+	@Override
+	public SubscriptionType getSubscriptionType() {
+		return this.subscriptionType;
+	}
+
+	public void setSubscriptionType(SubscriptionType subscriptionType) {
+		this.subscriptionType = subscriptionType;
+	}
+
+	@Override
+	public KeySharedPolicy getKeySharedPolicy() {
+		return this.keySharedPolicy;
+	}
+
+	public void setKeySharedPolicy(KeySharedPolicy keySharedPolicy) {
+		this.keySharedPolicy = keySharedPolicy;
+	}
+
+	@Override
+	public Boolean getReplicateSubscriptionState() {
+		return this.replicateSubscriptionState;
+	}
+
+	public void setReplicateSubscriptionState(Boolean replicateSubscriptionState) {
+		this.replicateSubscriptionState = replicateSubscriptionState;
+	}
+
+	@Override
+	public Map<String, String> getSubscriptionProperties() {
+		return this.subscriptionProperties;
+	}
+
+	public void setSubscriptionProperties(Map<String, String> subscriptionProperties) {
+		this.subscriptionProperties = subscriptionProperties;
+	}
+
+	@Override
+	public String getConsumerName() {
+		return this.consumerName;
+	}
+
+	public void setConsumerName(String consumerName) {
+		this.consumerName = consumerName;
+	}
+
+	@Override
+	public Map<String, String> getProperties() {
+		return this.properties;
+	}
+
+	public void setProperties(Map<String, String> properties) {
+		this.properties = properties;
+	}
+
+	@Override
+	public Integer getPriorityLevel() {
+		return this.priorityLevel;
+	}
+
+	public void setPriorityLevel(Integer priorityLevel) {
+		this.priorityLevel = priorityLevel;
+	}
+
+	@Override
+	public Boolean getReadCompacted() {
+		return this.readCompacted;
+	}
+
+	public void setReadCompacted(Boolean readCompacted) {
+		this.readCompacted = readCompacted;
+	}
+
+	@Override
+	public Boolean getBatchIndexAckEnabled() {
+		return this.batchIndexAckEnabled;
+	}
+
+	public void setBatchIndexAckEnabled(Boolean batchIndexAckEnabled) {
+		this.batchIndexAckEnabled = batchIndexAckEnabled;
+	}
+
+	@Override
+	public Duration getAckTimeout() {
+		return this.ackTimeout;
+	}
+
+	public void setAckTimeout(Duration ackTimeout) {
+		this.ackTimeout = ackTimeout;
+	}
+
+	@Override
+	public Duration getAckTimeoutTickTime() {
+		return this.ackTimeoutTickTime;
+	}
+
+	public void setAckTimeoutTickTime(Duration ackTimeoutTickTime) {
+		this.ackTimeoutTickTime = ackTimeoutTickTime;
+	}
+
+	@Override
+	public Duration getAcknowledgementsGroupTime() {
+		return this.acknowledgementsGroupTime;
+	}
+
+	public void setAcknowledgementsGroupTime(Duration acknowledgementsGroupTime) {
+		this.acknowledgementsGroupTime = acknowledgementsGroupTime;
+	}
+
+	@Override
+	public Boolean getAcknowledgeAsynchronously() {
+		return this.acknowledgeAsynchronously;
+	}
+
+	public void setAcknowledgeAsynchronously(Boolean acknowledgeAsynchronously) {
+		this.acknowledgeAsynchronously = acknowledgeAsynchronously;
+	}
+
+	@Override
+	public Scheduler getAcknowledgeScheduler() {
+		return this.acknowledgeScheduler;
+	}
+
+	public void setAcknowledgeScheduler(Scheduler acknowledgeScheduler) {
+		this.acknowledgeScheduler = acknowledgeScheduler;
+	}
+
+	@Override
+	public Duration getNegativeAckRedeliveryDelay() {
+		return this.negativeAckRedeliveryDelay;
+	}
+
+	public void setNegativeAckRedeliveryDelay(Duration negativeAckRedeliveryDelay) {
+		this.negativeAckRedeliveryDelay = negativeAckRedeliveryDelay;
+	}
+
+	@Override
+	public DeadLetterPolicy getDeadLetterPolicy() {
+		return this.deadLetterPolicy;
+	}
+
+	public void setDeadLetterPolicy(DeadLetterPolicy deadLetterPolicy) {
+		this.deadLetterPolicy = deadLetterPolicy;
+	}
+
+	@Override
+	public Boolean getRetryLetterTopicEnable() {
+		return this.retryLetterTopicEnable;
+	}
+
+	public void setRetryLetterTopicEnable(Boolean retryLetterTopicEnable) {
+		this.retryLetterTopicEnable = retryLetterTopicEnable;
+	}
+
+	@Override
+	public Integer getReceiverQueueSize() {
+		return this.receiverQueueSize;
+	}
+
+	public void setReceiverQueueSize(Integer receiverQueueSize) {
+		this.receiverQueueSize = receiverQueueSize;
+	}
+
+	@Override
+	public Integer getMaxTotalReceiverQueueSizeAcrossPartitions() {
+		return this.maxTotalReceiverQueueSizeAcrossPartitions;
+	}
+
+	public void setMaxTotalReceiverQueueSizeAcrossPartitions(Integer maxTotalReceiverQueueSizeAcrossPartitions) {
+		this.maxTotalReceiverQueueSizeAcrossPartitions = maxTotalReceiverQueueSizeAcrossPartitions;
+	}
+
+	@Override
+	public Boolean getAutoUpdatePartitions() {
+		return this.autoUpdatePartitions;
+	}
+
+	public void setAutoUpdatePartitions(Boolean autoUpdatePartitions) {
+		this.autoUpdatePartitions = autoUpdatePartitions;
+	}
+
+	@Override
+	public Duration getAutoUpdatePartitionsInterval() {
+		return this.autoUpdatePartitionsInterval;
+	}
+
+	public void setAutoUpdatePartitionsInterval(Duration autoUpdatePartitionsInterval) {
+		this.autoUpdatePartitionsInterval = autoUpdatePartitionsInterval;
+	}
+
+	@Override
+	public CryptoKeyReader getCryptoKeyReader() {
+		return this.cryptoKeyReader;
+	}
+
+	public void setCryptoKeyReader(CryptoKeyReader cryptoKeyReader) {
+		this.cryptoKeyReader = cryptoKeyReader;
+	}
+
+	@Override
+	public ConsumerCryptoFailureAction getCryptoFailureAction() {
+		return this.cryptoFailureAction;
+	}
+
+	public void setCryptoFailureAction(ConsumerCryptoFailureAction cryptoFailureAction) {
+		this.cryptoFailureAction = cryptoFailureAction;
+	}
+
+	@Override
+	public Integer getMaxPendingChunkedMessage() {
+		return this.maxPendingChunkedMessage;
+	}
+
+	public void setMaxPendingChunkedMessage(Integer maxPendingChunkedMessage) {
+		this.maxPendingChunkedMessage = maxPendingChunkedMessage;
+	}
+
+	@Override
+	public Boolean getAutoAckOldestChunkedMessageOnQueueFull() {
+		return this.autoAckOldestChunkedMessageOnQueueFull;
+	}
+
+	public void setAutoAckOldestChunkedMessageOnQueueFull(Boolean autoAckOldestChunkedMessageOnQueueFull) {
+		this.autoAckOldestChunkedMessageOnQueueFull = autoAckOldestChunkedMessageOnQueueFull;
+	}
+
+	@Override
+	public Duration getExpireTimeOfIncompleteChunkedMessage() {
+		return this.expireTimeOfIncompleteChunkedMessage;
+	}
+
+	public void setExpireTimeOfIncompleteChunkedMessage(Duration expireTimeOfIncompleteChunkedMessage) {
+		this.expireTimeOfIncompleteChunkedMessage = expireTimeOfIncompleteChunkedMessage;
+	}
+
+	public void applySpec(ReactiveMessageConsumerSpec consumerSpec) {
+		if (consumerSpec.getTopicNames() != null && !consumerSpec.getTopicNames().isEmpty()) {
+			setTopicNames(new ArrayList<>(consumerSpec.getTopicNames()));
+		}
+		if (consumerSpec.getTopicsPattern() != null) {
+			setTopicsPattern(consumerSpec.getTopicsPattern());
+		}
+		if (consumerSpec.getTopicsPatternSubscriptionMode() != null) {
+			setTopicsPatternSubscriptionMode(consumerSpec.getTopicsPatternSubscriptionMode());
+		}
+		if (consumerSpec.getTopicsPatternAutoDiscoveryPeriod() != null) {
+			setTopicsPatternAutoDiscoveryPeriod(consumerSpec.getTopicsPatternAutoDiscoveryPeriod());
+		}
+		if (consumerSpec.getSubscriptionName() != null) {
+			setSubscriptionName(consumerSpec.getSubscriptionName());
+		}
+		if (consumerSpec.getSubscriptionMode() != null) {
+			setSubscriptionMode(consumerSpec.getSubscriptionMode());
+		}
+		if (consumerSpec.getSubscriptionType() != null) {
+			setSubscriptionType(consumerSpec.getSubscriptionType());
+		}
+		if (consumerSpec.getKeySharedPolicy() != null) {
+			setKeySharedPolicy(consumerSpec.getKeySharedPolicy());
+		}
+		if (consumerSpec.getReplicateSubscriptionState() != null) {
+			setReplicateSubscriptionState(consumerSpec.getReplicateSubscriptionState());
+		}
+		if (consumerSpec.getSubscriptionProperties() != null && !consumerSpec.getSubscriptionProperties().isEmpty()) {
+			setSubscriptionProperties(new LinkedHashMap<>(consumerSpec.getSubscriptionProperties()));
+		}
+		if (consumerSpec.getConsumerName() != null) {
+			setConsumerName(consumerSpec.getConsumerName());
+		}
+		if (consumerSpec.getProperties() != null && !consumerSpec.getProperties().isEmpty()) {
+			setProperties(new LinkedHashMap<>(consumerSpec.getProperties()));
+		}
+		if (consumerSpec.getPriorityLevel() != null) {
+			setPriorityLevel(consumerSpec.getPriorityLevel());
+		}
+		if (consumerSpec.getReadCompacted() != null) {
+			setReadCompacted(consumerSpec.getReadCompacted());
+		}
+		if (consumerSpec.getBatchIndexAckEnabled() != null) {
+			setBatchIndexAckEnabled(consumerSpec.getBatchIndexAckEnabled());
+		}
+		if (consumerSpec.getAckTimeout() != null) {
+			setAckTimeout(consumerSpec.getAckTimeout());
+		}
+		if (consumerSpec.getAckTimeoutTickTime() != null) {
+			setAckTimeoutTickTime(consumerSpec.getAckTimeoutTickTime());
+		}
+		if (consumerSpec.getAcknowledgementsGroupTime() != null) {
+			setAcknowledgementsGroupTime(consumerSpec.getAcknowledgementsGroupTime());
+		}
+		if (consumerSpec.getNegativeAckRedeliveryDelay() != null) {
+			setNegativeAckRedeliveryDelay(consumerSpec.getNegativeAckRedeliveryDelay());
+		}
+		if (consumerSpec.getDeadLetterPolicy() != null) {
+			setDeadLetterPolicy(consumerSpec.getDeadLetterPolicy());
+		}
+		if (consumerSpec.getRetryLetterTopicEnable() != null) {
+			setRetryLetterTopicEnable(consumerSpec.getRetryLetterTopicEnable());
+		}
+		if (consumerSpec.getReceiverQueueSize() != null) {
+			setReceiverQueueSize(consumerSpec.getReceiverQueueSize());
+		}
+		if (consumerSpec.getMaxTotalReceiverQueueSizeAcrossPartitions() != null) {
+			setMaxTotalReceiverQueueSizeAcrossPartitions(consumerSpec.getMaxTotalReceiverQueueSizeAcrossPartitions());
+		}
+		if (consumerSpec.getAutoUpdatePartitions() != null) {
+			setAutoUpdatePartitions(consumerSpec.getAutoUpdatePartitions());
+		}
+		if (consumerSpec.getAutoUpdatePartitionsInterval() != null) {
+			setAutoUpdatePartitionsInterval(consumerSpec.getAutoUpdatePartitionsInterval());
+		}
+		if (consumerSpec.getCryptoKeyReader() != null) {
+			setCryptoKeyReader(consumerSpec.getCryptoKeyReader());
+		}
+		if (consumerSpec.getCryptoFailureAction() != null) {
+			setCryptoFailureAction(consumerSpec.getCryptoFailureAction());
+		}
+		if (consumerSpec.getMaxPendingChunkedMessage() != null) {
+			setMaxPendingChunkedMessage(consumerSpec.getMaxPendingChunkedMessage());
+		}
+		if (consumerSpec.getAutoAckOldestChunkedMessageOnQueueFull() != null) {
+			setAutoAckOldestChunkedMessageOnQueueFull(consumerSpec.getAutoAckOldestChunkedMessageOnQueueFull());
+		}
+		if (consumerSpec.getExpireTimeOfIncompleteChunkedMessage() != null) {
+			setExpireTimeOfIncompleteChunkedMessage(consumerSpec.getExpireTimeOfIncompleteChunkedMessage());
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageReaderSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageReaderSpec.java
new file mode 100644
index 0000000..020eadd
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageReaderSpec.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.Range;
+
+public class MutableReactiveMessageReaderSpec implements ReactiveMessageReaderSpec {
+
+	private List<String> topicNames = new ArrayList<>();
+
+	private String readerName;
+
+	private String subscriptionName;
+
+	private String generatedSubscriptionNamePrefix;
+
+	private Integer receiverQueueSize;
+
+	private Boolean readCompacted;
+
+	private List<Range> keyHashRanges;
+
+	private CryptoKeyReader cryptoKeyReader;
+
+	private ConsumerCryptoFailureAction cryptoFailureAction;
+
+	public MutableReactiveMessageReaderSpec() {
+
+	}
+
+	public MutableReactiveMessageReaderSpec(ReactiveMessageReaderSpec readerSpec) {
+		this.topicNames = (readerSpec.getTopicNames() != null && !readerSpec.getTopicNames().isEmpty())
+				? new ArrayList<>(readerSpec.getTopicNames()) : new ArrayList<>();
+		this.readerName = readerSpec.getReaderName();
+		this.subscriptionName = readerSpec.getSubscriptionName();
+		this.generatedSubscriptionNamePrefix = readerSpec.getGeneratedSubscriptionNamePrefix();
+		this.receiverQueueSize = readerSpec.getReceiverQueueSize();
+		this.readCompacted = readerSpec.getReadCompacted();
+		this.keyHashRanges = readerSpec.getKeyHashRanges();
+		this.cryptoKeyReader = readerSpec.getCryptoKeyReader();
+		this.cryptoFailureAction = readerSpec.getCryptoFailureAction();
+	}
+
+	@Override
+	public List<String> getTopicNames() {
+		return this.topicNames;
+	}
+
+	public void setTopicNames(List<String> topicNames) {
+		this.topicNames = topicNames;
+	}
+
+	@Override
+	public String getReaderName() {
+		return this.readerName;
+	}
+
+	public void setReaderName(String readerName) {
+		this.readerName = readerName;
+	}
+
+	@Override
+	public String getSubscriptionName() {
+		return this.subscriptionName;
+	}
+
+	public void setSubscriptionName(String subscriptionName) {
+		this.subscriptionName = subscriptionName;
+	}
+
+	@Override
+	public String getGeneratedSubscriptionNamePrefix() {
+		return this.generatedSubscriptionNamePrefix;
+	}
+
+	public void setGeneratedSubscriptionNamePrefix(String generatedSubscriptionNamePrefix) {
+		this.generatedSubscriptionNamePrefix = generatedSubscriptionNamePrefix;
+	}
+
+	@Override
+	public Integer getReceiverQueueSize() {
+		return this.receiverQueueSize;
+	}
+
+	public void setReceiverQueueSize(Integer receiverQueueSize) {
+		this.receiverQueueSize = receiverQueueSize;
+	}
+
+	@Override
+	public Boolean getReadCompacted() {
+		return this.readCompacted;
+	}
+
+	public void setReadCompacted(Boolean readCompacted) {
+		this.readCompacted = readCompacted;
+	}
+
+	@Override
+	public List<Range> getKeyHashRanges() {
+		return this.keyHashRanges;
+	}
+
+	public void setKeyHashRanges(List<Range> keyHashRanges) {
+		this.keyHashRanges = keyHashRanges;
+	}
+
+	@Override
+	public CryptoKeyReader getCryptoKeyReader() {
+		return this.cryptoKeyReader;
+	}
+
+	public void setCryptoKeyReader(CryptoKeyReader cryptoKeyReader) {
+		this.cryptoKeyReader = cryptoKeyReader;
+	}
+
+	@Override
+	public ConsumerCryptoFailureAction getCryptoFailureAction() {
+		return this.cryptoFailureAction;
+	}
+
+	public void setCryptoFailureAction(ConsumerCryptoFailureAction cryptoFailureAction) {
+		this.cryptoFailureAction = cryptoFailureAction;
+	}
+
+	public void applySpec(ReactiveMessageReaderSpec readerSpec) {
+		if (readerSpec.getTopicNames() != null && !readerSpec.getTopicNames().isEmpty()) {
+			setTopicNames(new ArrayList<>(readerSpec.getTopicNames()));
+		}
+		if (readerSpec.getReaderName() != null) {
+			setReaderName(readerSpec.getReaderName());
+		}
+		if (readerSpec.getSubscriptionName() != null) {
+			setSubscriptionName(readerSpec.getSubscriptionName());
+		}
+		if (readerSpec.getGeneratedSubscriptionNamePrefix() != null) {
+			setGeneratedSubscriptionNamePrefix(readerSpec.getGeneratedSubscriptionNamePrefix());
+		}
+		if (readerSpec.getReceiverQueueSize() != null) {
+			setReceiverQueueSize(readerSpec.getReceiverQueueSize());
+		}
+		if (readerSpec.getReadCompacted() != null) {
+			setReadCompacted(readerSpec.getReadCompacted());
+		}
+		if (readerSpec.getKeyHashRanges() != null && !readerSpec.getKeyHashRanges().isEmpty()) {
+			setKeyHashRanges(new ArrayList<>(readerSpec.getKeyHashRanges()));
+		}
+		if (readerSpec.getCryptoKeyReader() != null) {
+			setCryptoKeyReader(readerSpec.getCryptoKeyReader());
+		}
+		if (readerSpec.getCryptoFailureAction() != null) {
+			setCryptoFailureAction(readerSpec.getCryptoFailureAction());
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageSenderSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageSenderSpec.java
new file mode 100644
index 0000000..7d6bda7
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/MutableReactiveMessageSenderSpec.java
@@ -0,0 +1,452 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.pulsar.client.api.BatcherBuilder;
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRouter;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.ProducerCryptoFailureAction;
+
+public class MutableReactiveMessageSenderSpec implements ReactiveMessageSenderSpec {
+
+	private String topicName;
+
+	private String producerName;
+
+	private Duration sendTimeout;
+
+	private Integer maxPendingMessages;
+
+	private Integer maxPendingMessagesAcrossPartitions;
+
+	private MessageRoutingMode messageRoutingMode;
+
+	private HashingScheme hashingScheme;
+
+	private ProducerCryptoFailureAction cryptoFailureAction;
+
+	private MessageRouter messageRouter;
+
+	private Duration batchingMaxPublishDelay;
+
+	private Integer roundRobinRouterBatchingPartitionSwitchFrequency;
+
+	private Integer batchingMaxMessages;
+
+	private Integer batchingMaxBytes;
+
+	private Boolean batchingEnabled;
+
+	private BatcherBuilder batcherBuilder;
+
+	private Boolean chunkingEnabled;
+
+	private CryptoKeyReader cryptoKeyReader;
+
+	private Set<String> encryptionKeys;
+
+	private CompressionType compressionType;
+
+	private Long initialSequenceId;
+
+	private Boolean autoUpdatePartitions;
+
+	private Duration autoUpdatePartitionsInterval;
+
+	private Boolean multiSchema;
+
+	private ProducerAccessMode accessMode;
+
+	private Boolean lazyStartPartitionedProducers;
+
+	private Map<String, String> properties;
+
+	private String initialSubscriptionName;
+
+	public MutableReactiveMessageSenderSpec() {
+
+	}
+
+	public MutableReactiveMessageSenderSpec(ReactiveMessageSenderSpec senderSpec) {
+		this.topicName = senderSpec.getTopicName();
+		this.producerName = senderSpec.getProducerName();
+		this.sendTimeout = senderSpec.getSendTimeout();
+		this.maxPendingMessages = senderSpec.getMaxPendingMessages();
+		this.maxPendingMessagesAcrossPartitions = senderSpec.getMaxPendingMessagesAcrossPartitions();
+		this.messageRoutingMode = senderSpec.getMessageRoutingMode();
+		this.hashingScheme = senderSpec.getHashingScheme();
+		this.cryptoFailureAction = senderSpec.getCryptoFailureAction();
+		this.messageRouter = senderSpec.getMessageRouter();
+		this.batchingMaxPublishDelay = senderSpec.getBatchingMaxPublishDelay();
+		this.roundRobinRouterBatchingPartitionSwitchFrequency = senderSpec
+				.getRoundRobinRouterBatchingPartitionSwitchFrequency();
+		this.batchingMaxMessages = senderSpec.getBatchingMaxMessages();
+		this.batchingMaxBytes = senderSpec.getBatchingMaxBytes();
+		this.batchingEnabled = senderSpec.getBatchingEnabled();
+		this.batcherBuilder = senderSpec.getBatcherBuilder();
+		this.chunkingEnabled = senderSpec.getChunkingEnabled();
+		this.cryptoKeyReader = senderSpec.getCryptoKeyReader();
+		this.encryptionKeys = (senderSpec.getEncryptionKeys() != null && !senderSpec.getEncryptionKeys().isEmpty())
+				? new HashSet<>(senderSpec.getEncryptionKeys()) : null;
+
+		this.compressionType = senderSpec.getCompressionType();
+		this.initialSequenceId = senderSpec.getInitialSequenceId();
+		this.autoUpdatePartitions = senderSpec.getAutoUpdatePartitions();
+		this.autoUpdatePartitionsInterval = senderSpec.getAutoUpdatePartitionsInterval();
+		this.multiSchema = senderSpec.getMultiSchema();
+		this.accessMode = senderSpec.getAccessMode();
+		this.lazyStartPartitionedProducers = senderSpec.getLazyStartPartitionedProducers();
+		this.properties = (senderSpec.getProperties() != null && !senderSpec.getProperties().isEmpty())
+				? Collections.unmodifiableMap(new LinkedHashMap<>(senderSpec.getProperties())) : null;
+	}
+
+	public String getTopicName() {
+		return this.topicName;
+	}
+
+	public void setTopicName(String topicName) {
+		this.topicName = topicName;
+	}
+
+	public String getProducerName() {
+		return this.producerName;
+	}
+
+	public void setProducerName(String producerName) {
+		this.producerName = producerName;
+	}
+
+	public Duration getSendTimeout() {
+		return this.sendTimeout;
+	}
+
+	public void setSendTimeout(Duration sendTimeout) {
+		this.sendTimeout = sendTimeout;
+	}
+
+	public Integer getMaxPendingMessages() {
+		return this.maxPendingMessages;
+	}
+
+	public void setMaxPendingMessages(Integer maxPendingMessages) {
+		this.maxPendingMessages = maxPendingMessages;
+	}
+
+	public Integer getMaxPendingMessagesAcrossPartitions() {
+		return this.maxPendingMessagesAcrossPartitions;
+	}
+
+	public void setMaxPendingMessagesAcrossPartitions(Integer maxPendingMessagesAcrossPartitions) {
+		this.maxPendingMessagesAcrossPartitions = maxPendingMessagesAcrossPartitions;
+	}
+
+	public MessageRoutingMode getMessageRoutingMode() {
+		return this.messageRoutingMode;
+	}
+
+	public void setMessageRoutingMode(MessageRoutingMode messageRoutingMode) {
+		this.messageRoutingMode = messageRoutingMode;
+	}
+
+	public HashingScheme getHashingScheme() {
+		return this.hashingScheme;
+	}
+
+	public void setHashingScheme(HashingScheme hashingScheme) {
+		this.hashingScheme = hashingScheme;
+	}
+
+	public ProducerCryptoFailureAction getCryptoFailureAction() {
+		return this.cryptoFailureAction;
+	}
+
+	public void setCryptoFailureAction(ProducerCryptoFailureAction cryptoFailureAction) {
+		this.cryptoFailureAction = cryptoFailureAction;
+	}
+
+	public MessageRouter getMessageRouter() {
+		return this.messageRouter;
+	}
+
+	public void setMessageRouter(MessageRouter messageRouter) {
+		this.messageRouter = messageRouter;
+	}
+
+	public Duration getBatchingMaxPublishDelay() {
+		return this.batchingMaxPublishDelay;
+	}
+
+	public void setBatchingMaxPublishDelay(Duration batchingMaxPublishDelay) {
+		this.batchingMaxPublishDelay = batchingMaxPublishDelay;
+	}
+
+	public Integer getRoundRobinRouterBatchingPartitionSwitchFrequency() {
+		return this.roundRobinRouterBatchingPartitionSwitchFrequency;
+	}
+
+	public void setRoundRobinRouterBatchingPartitionSwitchFrequency(
+			Integer roundRobinRouterBatchingPartitionSwitchFrequency) {
+		this.roundRobinRouterBatchingPartitionSwitchFrequency = roundRobinRouterBatchingPartitionSwitchFrequency;
+	}
+
+	public Integer getBatchingMaxMessages() {
+		return this.batchingMaxMessages;
+	}
+
+	public void setBatchingMaxMessages(Integer batchingMaxMessages) {
+		this.batchingMaxMessages = batchingMaxMessages;
+	}
+
+	public Integer getBatchingMaxBytes() {
+		return this.batchingMaxBytes;
+	}
+
+	public void setBatchingMaxBytes(Integer batchingMaxBytes) {
+		this.batchingMaxBytes = batchingMaxBytes;
+	}
+
+	public Boolean getBatchingEnabled() {
+		return this.batchingEnabled;
+	}
+
+	public void setBatchingEnabled(Boolean batchingEnabled) {
+		this.batchingEnabled = batchingEnabled;
+	}
+
+	public BatcherBuilder getBatcherBuilder() {
+		return this.batcherBuilder;
+	}
+
+	public void setBatcherBuilder(BatcherBuilder batcherBuilder) {
+		this.batcherBuilder = batcherBuilder;
+	}
+
+	public Boolean getChunkingEnabled() {
+		return this.chunkingEnabled;
+	}
+
+	public void setChunkingEnabled(Boolean chunkingEnabled) {
+		this.chunkingEnabled = chunkingEnabled;
+	}
+
+	public CryptoKeyReader getCryptoKeyReader() {
+		return this.cryptoKeyReader;
+	}
+
+	public void setCryptoKeyReader(CryptoKeyReader cryptoKeyReader) {
+		this.cryptoKeyReader = cryptoKeyReader;
+	}
+
+	public Set<String> getEncryptionKeys() {
+		return this.encryptionKeys;
+	}
+
+	public void setEncryptionKeys(Set<String> encryptionKeys) {
+		this.encryptionKeys = encryptionKeys;
+	}
+
+	public CompressionType getCompressionType() {
+		return this.compressionType;
+	}
+
+	public void setCompressionType(CompressionType compressionType) {
+		this.compressionType = compressionType;
+	}
+
+	public Long getInitialSequenceId() {
+		return this.initialSequenceId;
+	}
+
+	public void setInitialSequenceId(Long initialSequenceId) {
+		this.initialSequenceId = initialSequenceId;
+	}
+
+	public Boolean getAutoUpdatePartitions() {
+		return this.autoUpdatePartitions;
+	}
+
+	public void setAutoUpdatePartitions(Boolean autoUpdatePartitions) {
+		this.autoUpdatePartitions = autoUpdatePartitions;
+	}
+
+	public Duration getAutoUpdatePartitionsInterval() {
+		return this.autoUpdatePartitionsInterval;
+	}
+
+	public void setAutoUpdatePartitionsInterval(Duration autoUpdatePartitionsInterval) {
+		this.autoUpdatePartitionsInterval = autoUpdatePartitionsInterval;
+	}
+
+	public Boolean getMultiSchema() {
+		return this.multiSchema;
+	}
+
+	public void setMultiSchema(Boolean multiSchema) {
+		this.multiSchema = multiSchema;
+	}
+
+	public ProducerAccessMode getAccessMode() {
+		return this.accessMode;
+	}
+
+	public void setAccessMode(ProducerAccessMode accessMode) {
+		this.accessMode = accessMode;
+	}
+
+	public Boolean getLazyStartPartitionedProducers() {
+		return this.lazyStartPartitionedProducers;
+	}
+
+	public void setLazyStartPartitionedProducers(Boolean lazyStartPartitionedProducers) {
+		this.lazyStartPartitionedProducers = lazyStartPartitionedProducers;
+	}
+
+	public Map<String, String> getProperties() {
+		return this.properties;
+	}
+
+	public void setProperties(Map<String, String> properties) {
+		this.properties = properties;
+	}
+
+	public String getInitialSubscriptionName() {
+		return this.initialSubscriptionName;
+	}
+
+	public void setInitialSubscriptionName(String initialSubscriptionName) {
+		this.initialSubscriptionName = initialSubscriptionName;
+	}
+
+	public void applySpec(ReactiveMessageSenderSpec senderSpec) {
+		if (senderSpec.getTopicName() != null) {
+			setTopicName(senderSpec.getTopicName());
+		}
+
+		if (senderSpec.getProducerName() != null) {
+			setProducerName(senderSpec.getProducerName());
+		}
+
+		if (senderSpec.getSendTimeout() != null) {
+			setSendTimeout(senderSpec.getSendTimeout());
+		}
+
+		if (senderSpec.getMaxPendingMessages() != null) {
+			setMaxPendingMessages(senderSpec.getMaxPendingMessages());
+		}
+
+		if (senderSpec.getMaxPendingMessagesAcrossPartitions() != null) {
+			setMaxPendingMessagesAcrossPartitions(senderSpec.getMaxPendingMessagesAcrossPartitions());
+		}
+
+		if (senderSpec.getMessageRoutingMode() != null) {
+			setMessageRoutingMode(senderSpec.getMessageRoutingMode());
+		}
+
+		if (senderSpec.getHashingScheme() != null) {
+			setHashingScheme(senderSpec.getHashingScheme());
+		}
+
+		if (senderSpec.getCryptoFailureAction() != null) {
+			setCryptoFailureAction(senderSpec.getCryptoFailureAction());
+		}
+
+		if (senderSpec.getMessageRouter() != null) {
+			setMessageRouter(senderSpec.getMessageRouter());
+		}
+
+		if (senderSpec.getBatchingMaxPublishDelay() != null) {
+			setBatchingMaxPublishDelay(senderSpec.getBatchingMaxPublishDelay());
+		}
+
+		if (senderSpec.getRoundRobinRouterBatchingPartitionSwitchFrequency() != null) {
+			setRoundRobinRouterBatchingPartitionSwitchFrequency(
+					senderSpec.getRoundRobinRouterBatchingPartitionSwitchFrequency());
+		}
+
+		if (senderSpec.getBatchingMaxMessages() != null) {
+			setBatchingMaxMessages(senderSpec.getBatchingMaxMessages());
+		}
+
+		if (senderSpec.getBatchingMaxBytes() != null) {
+			setBatchingMaxBytes(senderSpec.getBatchingMaxBytes());
+		}
+
+		if (senderSpec.getBatchingEnabled() != null) {
+			setBatchingEnabled(senderSpec.getBatchingEnabled());
+		}
+
+		if (senderSpec.getBatcherBuilder() != null) {
+			setBatcherBuilder(senderSpec.getBatcherBuilder());
+		}
+
+		if (senderSpec.getChunkingEnabled() != null) {
+			setChunkingEnabled(senderSpec.getChunkingEnabled());
+		}
+
+		if (senderSpec.getCryptoKeyReader() != null) {
+			setCryptoKeyReader(senderSpec.getCryptoKeyReader());
+		}
+
+		if (senderSpec.getEncryptionKeys() != null && !senderSpec.getEncryptionKeys().isEmpty()) {
+			setEncryptionKeys(new HashSet<>(senderSpec.getEncryptionKeys()));
+		}
+
+		if (senderSpec.getCompressionType() != null) {
+			setCompressionType(senderSpec.getCompressionType());
+		}
+
+		if (senderSpec.getInitialSequenceId() != null) {
+			setInitialSequenceId(senderSpec.getInitialSequenceId());
+		}
+
+		if (senderSpec.getAutoUpdatePartitions() != null) {
+			setAutoUpdatePartitions(senderSpec.getAutoUpdatePartitions());
+		}
+
+		if (senderSpec.getAutoUpdatePartitionsInterval() != null) {
+			setAutoUpdatePartitionsInterval(senderSpec.getAutoUpdatePartitionsInterval());
+		}
+
+		if (senderSpec.getMultiSchema() != null) {
+			setMultiSchema(senderSpec.getMultiSchema());
+		}
+
+		if (senderSpec.getAccessMode() != null) {
+			setAccessMode(senderSpec.getAccessMode());
+		}
+
+		if (senderSpec.getLazyStartPartitionedProducers() != null) {
+			setLazyStartPartitionedProducers(senderSpec.getLazyStartPartitionedProducers());
+		}
+
+		if (senderSpec.getProperties() != null && !senderSpec.getProperties().isEmpty()) {
+			setProperties(new LinkedHashMap<>(senderSpec.getProperties()));
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumer.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumer.java
new file mode 100644
index 0000000..728f089
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Message;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public interface ReactiveMessageConsumer<T> {
+
+	<R> Mono<R> consumeMessage(Function<Mono<Message<T>>, Mono<MessageResult<R>>> messageHandler);
+
+	<R> Flux<R> consumeMessages(Function<Flux<Message<T>>, Flux<MessageResult<R>>> messageHandler);
+
+	/**
+	 * Creates the Pulsar Consumer and immediately closes it. This is useful for creating
+	 * the Pulsar subscription that is related to the consumer. Nothing happens unless the
+	 * returned Mono is subscribed.
+	 * @return a Mono for consuming nothing
+	 */
+	Mono<Void> consumeNothing();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumerBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumerBuilder.java
new file mode 100644
index 0000000..b3cb76b
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumerBuilder.java
@@ -0,0 +1,248 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.KeySharedPolicy;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import reactor.core.scheduler.Scheduler;
+
+public interface ReactiveMessageConsumerBuilder<T> {
+
+	default ReactiveMessageConsumerBuilder<T> applySpec(ReactiveMessageConsumerSpec consumerSpec) {
+		getMutableSpec().applySpec(consumerSpec);
+		return this;
+	}
+
+	ReactiveMessageConsumerSpec toImmutableSpec();
+
+	MutableReactiveMessageConsumerSpec getMutableSpec();
+
+	default ReactiveMessageConsumerBuilder<T> topic(String topicName) {
+		getMutableSpec().getTopicNames().add(topicName);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> topic(String... topicNames) {
+		for (String topicName : topicNames) {
+			getMutableSpec().getTopicNames().add(topicName);
+		}
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> topicNames(List<String> topicNames) {
+		getMutableSpec().setTopicNames(topicNames);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> topicsPattern(Pattern topicsPattern) {
+		getMutableSpec().setTopicsPattern(topicsPattern);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> topicsPatternSubscriptionMode(
+			RegexSubscriptionMode topicsPatternSubscriptionMode) {
+		getMutableSpec().setTopicsPatternSubscriptionMode(topicsPatternSubscriptionMode);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> topicsPatternAutoDiscoveryPeriod(
+			Duration topicsPatternAutoDiscoveryPeriod) {
+		getMutableSpec().setTopicsPatternAutoDiscoveryPeriod(topicsPatternAutoDiscoveryPeriod);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> subscriptionName(String subscriptionName) {
+		getMutableSpec().setSubscriptionName(subscriptionName);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> subscriptionMode(SubscriptionMode subscriptionMode) {
+		getMutableSpec().setSubscriptionMode(subscriptionMode);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> subscriptionType(SubscriptionType subscriptionType) {
+		getMutableSpec().setSubscriptionType(subscriptionType);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> keySharedPolicy(KeySharedPolicy keySharedPolicy) {
+		getMutableSpec().setKeySharedPolicy(keySharedPolicy);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> replicateSubscriptionState(boolean replicateSubscriptionState) {
+		getMutableSpec().setReplicateSubscriptionState(replicateSubscriptionState);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> subscriptionProperties(Map<String, String> subscriptionProperties) {
+		getMutableSpec().setSubscriptionProperties(subscriptionProperties);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> subscriptionProperty(String key, String value) {
+		if (getMutableSpec().getSubscriptionProperties() == null) {
+			getMutableSpec().setSubscriptionProperties(new LinkedHashMap<>());
+		}
+		getMutableSpec().getSubscriptionProperties().put(key, value);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> consumerName(String consumerName) {
+		getMutableSpec().setConsumerName(consumerName);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> properties(Map<String, String> properties) {
+		getMutableSpec().setProperties(properties);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> property(String key, String value) {
+		if (getMutableSpec().getProperties() == null) {
+			getMutableSpec().setProperties(new LinkedHashMap<>());
+		}
+		getMutableSpec().getProperties().put(key, value);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> priorityLevel(Integer priorityLevel) {
+		getMutableSpec().setPriorityLevel(priorityLevel);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> readCompacted(boolean readCompacted) {
+		getMutableSpec().setReadCompacted(readCompacted);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> batchIndexAckEnabled(boolean batchIndexAckEnabled) {
+		getMutableSpec().setBatchIndexAckEnabled(batchIndexAckEnabled);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> ackTimeout(Duration ackTimeout) {
+		getMutableSpec().setAckTimeout(ackTimeout);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> ackTimeoutTickTime(Duration ackTimeoutTickTime) {
+		getMutableSpec().setAckTimeoutTickTime(ackTimeoutTickTime);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> acknowledgementsGroupTime(Duration acknowledgementsGroupTime) {
+		getMutableSpec().setAcknowledgementsGroupTime(acknowledgementsGroupTime);
+		return this;
+	}
+
+	/**
+	 * When set to true, ignores the acknowledge operation completion and makes it
+	 * asynchronous from the message consuming processing to improve performance by
+	 * allowing the acknowledges and message processing to interleave. Defaults to true.
+	 * @param acknowledgeAsynchronously when set to true, ignores the acknowledge
+	 * operation completion
+	 * @return the current ReactiveMessageConsumerFactory instance (this)
+	 */
+	default ReactiveMessageConsumerBuilder<T> acknowledgeAsynchronously(boolean acknowledgeAsynchronously) {
+		getMutableSpec().setAcknowledgeAsynchronously(acknowledgeAsynchronously);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> acknowledgeScheduler(Scheduler acknowledgeScheduler) {
+		getMutableSpec().setAcknowledgeScheduler(acknowledgeScheduler);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> negativeAckRedeliveryDelay(Duration negativeAckRedeliveryDelay) {
+		getMutableSpec().setNegativeAckRedeliveryDelay(negativeAckRedeliveryDelay);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> deadLetterPolicy(DeadLetterPolicy deadLetterPolicy) {
+		getMutableSpec().setDeadLetterPolicy(deadLetterPolicy);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> retryLetterTopicEnable(boolean retryLetterTopicEnable) {
+		getMutableSpec().setRetryLetterTopicEnable(retryLetterTopicEnable);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> receiverQueueSize(Integer receiverQueueSize) {
+		getMutableSpec().setReceiverQueueSize(receiverQueueSize);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> maxTotalReceiverQueueSizeAcrossPartitions(
+			Integer maxTotalReceiverQueueSizeAcrossPartitions) {
+		getMutableSpec().setMaxTotalReceiverQueueSizeAcrossPartitions(maxTotalReceiverQueueSizeAcrossPartitions);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> autoUpdatePartitions(boolean autoUpdatePartitions) {
+		getMutableSpec().setAutoUpdatePartitions(autoUpdatePartitions);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> autoUpdatePartitionsInterval(Duration autoUpdatePartitionsInterval) {
+		getMutableSpec().setAutoUpdatePartitionsInterval(autoUpdatePartitionsInterval);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> cryptoKeyReader(CryptoKeyReader cryptoKeyReader) {
+		getMutableSpec().setCryptoKeyReader(cryptoKeyReader);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> cryptoFailureAction(ConsumerCryptoFailureAction cryptoFailureAction) {
+		getMutableSpec().setCryptoFailureAction(cryptoFailureAction);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> maxPendingChunkedMessage(Integer maxPendingChunkedMessage) {
+		getMutableSpec().setMaxPendingChunkedMessage(maxPendingChunkedMessage);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> autoAckOldestChunkedMessageOnQueueFull(
+			boolean autoAckOldestChunkedMessageOnQueueFull) {
+		getMutableSpec().setAutoAckOldestChunkedMessageOnQueueFull(autoAckOldestChunkedMessageOnQueueFull);
+		return this;
+	}
+
+	default ReactiveMessageConsumerBuilder<T> expireTimeOfIncompleteChunkedMessage(
+			Duration expireTimeOfIncompleteChunkedMessage) {
+		getMutableSpec().setExpireTimeOfIncompleteChunkedMessage(expireTimeOfIncompleteChunkedMessage);
+		return this;
+	}
+
+	ReactiveMessageConsumer<T> build();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumerSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumerSpec.java
new file mode 100644
index 0000000..46a212b
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageConsumerSpec.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.DeadLetterPolicy;
+import org.apache.pulsar.client.api.KeySharedPolicy;
+import org.apache.pulsar.client.api.RegexSubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionMode;
+import org.apache.pulsar.client.api.SubscriptionType;
+import reactor.core.scheduler.Scheduler;
+
+public interface ReactiveMessageConsumerSpec {
+
+	List<String> getTopicNames();
+
+	Pattern getTopicsPattern();
+
+	RegexSubscriptionMode getTopicsPatternSubscriptionMode();
+
+	Duration getTopicsPatternAutoDiscoveryPeriod();
+
+	String getSubscriptionName();
+
+	SubscriptionMode getSubscriptionMode();
+
+	SubscriptionType getSubscriptionType();
+
+	KeySharedPolicy getKeySharedPolicy();
+
+	Boolean getReplicateSubscriptionState();
+
+	Map<String, String> getSubscriptionProperties();
+
+	String getConsumerName();
+
+	Map<String, String> getProperties();
+
+	Integer getPriorityLevel();
+
+	Boolean getReadCompacted();
+
+	Boolean getBatchIndexAckEnabled();
+
+	Duration getAckTimeout();
+
+	Duration getAckTimeoutTickTime();
+
+	Duration getAcknowledgementsGroupTime();
+
+	Boolean getAcknowledgeAsynchronously();
+
+	Scheduler getAcknowledgeScheduler();
+
+	Duration getNegativeAckRedeliveryDelay();
+
+	DeadLetterPolicy getDeadLetterPolicy();
+
+	Boolean getRetryLetterTopicEnable();
+
+	Integer getReceiverQueueSize();
+
+	Integer getMaxTotalReceiverQueueSizeAcrossPartitions();
+
+	Boolean getAutoUpdatePartitions();
+
+	Duration getAutoUpdatePartitionsInterval();
+
+	CryptoKeyReader getCryptoKeyReader();
+
+	ConsumerCryptoFailureAction getCryptoFailureAction();
+
+	Integer getMaxPendingChunkedMessage();
+
+	Boolean getAutoAckOldestChunkedMessageOnQueueFull();
+
+	Duration getExpireTimeOfIncompleteChunkedMessage();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessagePipeline.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessagePipeline.java
new file mode 100644
index 0000000..88f8ac7
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessagePipeline.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+public interface ReactiveMessagePipeline extends AutoCloseable {
+
+	ReactiveMessagePipeline start();
+
+	ReactiveMessagePipeline stop();
+
+	boolean isRunning();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessagePipelineBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessagePipelineBuilder.java
new file mode 100644
index 0000000..ab8e51d
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessagePipelineBuilder.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Message;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.util.retry.Retry;
+
+public interface ReactiveMessagePipelineBuilder<T> {
+
+	OneByOneMessagePipelineBuilder<T> messageHandler(Function<Message<T>, Mono<Void>> messageHandler);
+
+	ReactiveMessagePipelineBuilder<T> streamingMessageHandler(
+			Function<Flux<Message<T>>, Flux<MessageResult<Void>>> streamingMessageHandler);
+
+	ReactiveMessagePipelineBuilder<T> transformPipeline(Function<Mono<Void>, Mono<Void>> transformer);
+
+	ReactiveMessagePipelineBuilder<T> pipelineRetrySpec(Retry pipelineRetrySpec);
+
+	ReactiveMessagePipeline build();
+
+	interface OneByOneMessagePipelineBuilder<T> extends ReactiveMessagePipelineBuilder<T> {
+
+		OneByOneMessagePipelineBuilder<T> handlingTimeout(Duration handlingTimeout);
+
+		OneByOneMessagePipelineBuilder<T> errorLogger(BiConsumer<Message<T>, Throwable> errorLogger);
+
+		ConcurrentOneByOneMessagePipelineBuilder<T> concurrent();
+
+	}
+
+	interface ConcurrentOneByOneMessagePipelineBuilder<T> extends OneByOneMessagePipelineBuilder<T> {
+
+		ConcurrentOneByOneMessagePipelineBuilder<T> useKeyOrderedProcessing();
+
+		ConcurrentOneByOneMessagePipelineBuilder<T> groupOrderedProcessing(MessageGroupingFunction groupingFunction);
+
+		ConcurrentOneByOneMessagePipelineBuilder<T> concurrency(int concurrency);
+
+		ConcurrentOneByOneMessagePipelineBuilder<T> maxInflight(int maxInflight);
+
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReader.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReader.java
new file mode 100644
index 0000000..9392b26
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReader.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import org.apache.pulsar.client.api.Message;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public interface ReactiveMessageReader<T> {
+
+	Mono<Message<T>> readMessage();
+
+	Flux<Message<T>> readMessages();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReaderBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReaderBuilder.java
new file mode 100644
index 0000000..9571d7a
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReaderBuilder.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.List;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.Range;
+
+public interface ReactiveMessageReaderBuilder<T> {
+
+	ReactiveMessageReaderBuilder<T> startAtSpec(StartAtSpec startAtSpec);
+
+	ReactiveMessageReaderBuilder<T> endOfStreamAction(EndOfStreamAction endOfStreamAction);
+
+	default ReactiveMessageReaderBuilder<T> applySpec(ReactiveMessageReaderSpec readerSpec) {
+		getMutableSpec().applySpec(readerSpec);
+		return this;
+	}
+
+	ReactiveMessageReaderSpec toImmutableSpec();
+
+	MutableReactiveMessageReaderSpec getMutableSpec();
+
+	ReactiveMessageReaderBuilder<T> clone();
+
+	ReactiveMessageReader<T> build();
+
+	default ReactiveMessageReaderBuilder<T> topic(String topicName) {
+		getMutableSpec().getTopicNames().add(topicName);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> topic(String... topicNames) {
+		for (String topicName : topicNames) {
+			getMutableSpec().getTopicNames().add(topicName);
+		}
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> topicNames(List<String> topicNames) {
+		getMutableSpec().setTopicNames(topicNames);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> readerName(String readerName) {
+		getMutableSpec().setReaderName(readerName);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> subscriptionName(String subscriptionName) {
+		getMutableSpec().setSubscriptionName(subscriptionName);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> generatedSubscriptionNamePrefix(String generatedSubscriptionNamePrefix) {
+		getMutableSpec().setGeneratedSubscriptionNamePrefix(generatedSubscriptionNamePrefix);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> receiverQueueSize(Integer receiverQueueSize) {
+		getMutableSpec().setReceiverQueueSize(receiverQueueSize);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> readCompacted(Boolean readCompacted) {
+		getMutableSpec().setReadCompacted(readCompacted);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> keyHashRanges(List<Range> keyHashRanges) {
+		getMutableSpec().setKeyHashRanges(keyHashRanges);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> cryptoKeyReader(CryptoKeyReader cryptoKeyReader) {
+		getMutableSpec().setCryptoKeyReader(cryptoKeyReader);
+		return this;
+	}
+
+	default ReactiveMessageReaderBuilder<T> cryptoFailureAction(ConsumerCryptoFailureAction cryptoFailureAction) {
+		getMutableSpec().setCryptoFailureAction(cryptoFailureAction);
+		return this;
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReaderSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReaderSpec.java
new file mode 100644
index 0000000..7b37f5e
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageReaderSpec.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.util.List;
+
+import org.apache.pulsar.client.api.ConsumerCryptoFailureAction;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.Range;
+
+public interface ReactiveMessageReaderSpec {
+
+	List<String> getTopicNames();
+
+	String getReaderName();
+
+	String getSubscriptionName();
+
+	String getGeneratedSubscriptionNamePrefix();
+
+	Integer getReceiverQueueSize();
+
+	Boolean getReadCompacted();
+
+	List<Range> getKeyHashRanges();
+
+	CryptoKeyReader getCryptoKeyReader();
+
+	ConsumerCryptoFailureAction getCryptoFailureAction();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSender.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSender.java
new file mode 100644
index 0000000..2edeb0f
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSender.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import org.apache.pulsar.client.api.MessageId;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public interface ReactiveMessageSender<T> {
+
+	Mono<MessageId> sendMessage(Mono<MessageSpec<T>> messageSpec);
+
+	Flux<MessageId> sendMessages(Flux<MessageSpec<T>> messageSpecs);
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderBuilder.java
new file mode 100644
index 0000000..e3fc4b3
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderBuilder.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.pulsar.client.api.BatcherBuilder;
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRouter;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.ProducerCryptoFailureAction;
+
+public interface ReactiveMessageSenderBuilder<T> {
+
+	ReactiveMessageSenderBuilder<T> cache(ReactiveMessageSenderCache producerCache);
+
+	ReactiveMessageSenderBuilder<T> maxInflight(int maxInflight);
+
+	ReactiveMessageSenderBuilder<T> maxConcurrentSenderSubscriptions(int maxConcurrentSenderSubscriptions);
+
+	default ReactiveMessageSenderBuilder<T> applySpec(ReactiveMessageSenderSpec senderSpec) {
+		getMutableSpec().applySpec(senderSpec);
+		return this;
+	}
+
+	ReactiveMessageSenderSpec toImmutableSpec();
+
+	MutableReactiveMessageSenderSpec getMutableSpec();
+
+	default ReactiveMessageSenderBuilder<T> topic(String topicName) {
+		getMutableSpec().setTopicName(topicName);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> producerName(String producerName) {
+		getMutableSpec().setProducerName(producerName);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> sendTimeout(Duration sendTimeout) {
+		getMutableSpec().setSendTimeout(sendTimeout);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> maxPendingMessages(int maxPendingMessages) {
+		getMutableSpec().setMaxPendingMessages(maxPendingMessages);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> maxPendingMessagesAcrossPartitions(int maxPendingMessagesAcrossPartitions) {
+		getMutableSpec().setMaxPendingMessagesAcrossPartitions(maxPendingMessagesAcrossPartitions);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> messageRoutingMode(MessageRoutingMode messageRoutingMode) {
+		getMutableSpec().setMessageRoutingMode(messageRoutingMode);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> hashingScheme(HashingScheme hashingScheme) {
+		getMutableSpec().setHashingScheme(hashingScheme);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> cryptoFailureAction(ProducerCryptoFailureAction cryptoFailureAction) {
+		getMutableSpec().setCryptoFailureAction(cryptoFailureAction);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> messageRouter(MessageRouter messageRouter) {
+		getMutableSpec().setMessageRouter(messageRouter);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> batchingMaxPublishDelay(Duration batchingMaxPublishDelay) {
+		getMutableSpec().setBatchingMaxPublishDelay(batchingMaxPublishDelay);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> roundRobinRouterBatchingPartitionSwitchFrequency(
+			int roundRobinRouterBatchingPartitionSwitchFrequency) {
+		getMutableSpec()
+				.setRoundRobinRouterBatchingPartitionSwitchFrequency(roundRobinRouterBatchingPartitionSwitchFrequency);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> batchingMaxMessages(int batchingMaxMessages) {
+		getMutableSpec().setBatchingMaxMessages(batchingMaxMessages);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> batchingMaxBytes(int batchingMaxBytes) {
+		getMutableSpec().setBatchingMaxBytes(batchingMaxBytes);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> batchingEnabled(boolean batchingEnabled) {
+		getMutableSpec().setBatchingEnabled(batchingEnabled);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> batcherBuilder(BatcherBuilder batcherBuilder) {
+		getMutableSpec().setBatcherBuilder(batcherBuilder);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> chunkingEnabled(boolean chunkingEnabled) {
+		getMutableSpec().setChunkingEnabled(chunkingEnabled);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> cryptoKeyReader(CryptoKeyReader cryptoKeyReader) {
+		getMutableSpec().setCryptoKeyReader(cryptoKeyReader);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> encryptionKeys(Set<String> encryptionKeys) {
+		getMutableSpec().setEncryptionKeys(encryptionKeys);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> compressionType(CompressionType compressionType) {
+		getMutableSpec().setCompressionType(compressionType);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> initialSequenceId(long initialSequenceId) {
+		getMutableSpec().setInitialSequenceId(initialSequenceId);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> autoUpdatePartitions(boolean autoUpdatePartitions) {
+		getMutableSpec().setAutoUpdatePartitions(autoUpdatePartitions);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> autoUpdatePartitionsInterval(Duration autoUpdatePartitionsInterval) {
+		getMutableSpec().setAutoUpdatePartitionsInterval(autoUpdatePartitionsInterval);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> multiSchema(boolean multiSchema) {
+		getMutableSpec().setMultiSchema(multiSchema);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> accessMode(ProducerAccessMode accessMode) {
+		getMutableSpec().setAccessMode(accessMode);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> lazyStartPartitionedProducers(boolean lazyStartPartitionedProducers) {
+		getMutableSpec().setLazyStartPartitionedProducers(lazyStartPartitionedProducers);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> properties(Map<String, String> properties) {
+		getMutableSpec().setProperties(properties);
+		return this;
+	}
+
+	default ReactiveMessageSenderBuilder<T> initialSubscriptionName(String initialSubscriptionName) {
+		getMutableSpec().setInitialSubscriptionName(initialSubscriptionName);
+		return this;
+	}
+
+	ReactiveMessageSender<T> build();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderCache.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderCache.java
new file mode 100644
index 0000000..99f8bce
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderCache.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+/**
+ * Marker interface for a cache that combines multiple sent messages to share the same
+ * Pulsar producer in the Pulsar client implementation level.
+ *
+ * @author Lari Hotari
+ */
+public interface ReactiveMessageSenderCache extends AutoCloseable {
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderSpec.java
new file mode 100644
index 0000000..3bf87c9
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactiveMessageSenderSpec.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.pulsar.client.api.BatcherBuilder;
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.CryptoKeyReader;
+import org.apache.pulsar.client.api.HashingScheme;
+import org.apache.pulsar.client.api.MessageRouter;
+import org.apache.pulsar.client.api.MessageRoutingMode;
+import org.apache.pulsar.client.api.ProducerAccessMode;
+import org.apache.pulsar.client.api.ProducerCryptoFailureAction;
+
+public interface ReactiveMessageSenderSpec {
+
+	String getTopicName();
+
+	String getProducerName();
+
+	Duration getSendTimeout();
+
+	Integer getMaxPendingMessages();
+
+	Integer getMaxPendingMessagesAcrossPartitions();
+
+	MessageRoutingMode getMessageRoutingMode();
+
+	HashingScheme getHashingScheme();
+
+	ProducerCryptoFailureAction getCryptoFailureAction();
+
+	MessageRouter getMessageRouter();
+
+	Duration getBatchingMaxPublishDelay();
+
+	Integer getRoundRobinRouterBatchingPartitionSwitchFrequency();
+
+	Integer getBatchingMaxMessages();
+
+	Integer getBatchingMaxBytes();
+
+	Boolean getBatchingEnabled();
+
+	BatcherBuilder getBatcherBuilder();
+
+	Boolean getChunkingEnabled();
+
+	CryptoKeyReader getCryptoKeyReader();
+
+	Set<String> getEncryptionKeys();
+
+	CompressionType getCompressionType();
+
+	Long getInitialSequenceId();
+
+	Boolean getAutoUpdatePartitions();
+
+	Duration getAutoUpdatePartitionsInterval();
+
+	Boolean getMultiSchema();
+
+	ProducerAccessMode getAccessMode();
+
+	Boolean getLazyStartPartitionedProducers();
+
+	Map<String, String> getProperties();
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactivePulsarClient.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactivePulsarClient.java
new file mode 100644
index 0000000..ed5d6fc
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/ReactivePulsarClient.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.reactive.client.internal.api.ApiImplementationFactory;
+
+/**
+ * Apache Pulsar Reactive Client interface
+ *
+ * Contains methods to create builders for {@link ReactiveMessageSender},
+ * {@link ReactiveMessageReader} {@link ReactiveMessageConsumer} and
+ * {@link ReactiveMessagePipeline} instances.
+ *
+ * @author Lari Hotari
+ */
+public interface ReactivePulsarClient {
+
+	/**
+	 * Creates a builder for building a {@link ReactiveMessageSender}.
+	 * @param schema the Pulsar Java client Schema for the message payload
+	 * @param <T> the message payload type
+	 * @return a builder for building a {@link ReactiveMessageSender}
+	 */
+	<T> ReactiveMessageSenderBuilder<T> messageSender(Schema<T> schema);
+
+	/**
+	 * Creates a builder for building a {@link ReactiveMessageReader}.
+	 * @param schema the Pulsar Java client Schema for the message payload
+	 * @param <T> the message payload type
+	 * @return a builder for building a {@link ReactiveMessageReader}
+	 */
+	<T> ReactiveMessageReaderBuilder<T> messageReader(Schema<T> schema);
+
+	/**
+	 * Creates a builder for building a {@link ReactiveMessageConsumer}.
+	 * @param schema the Pulsar Java client Schema for the message payload
+	 * @param <T> the message payload type
+	 * @return a builder for building a {@link ReactiveMessageConsumer}
+	 */
+	<T> ReactiveMessageConsumerBuilder<T> messageConsumer(Schema<T> schema);
+
+	/**
+	 * Creates a builder for building a {@link ReactiveMessagePipeline}.
+	 * @param messageConsumer the {@link ReactiveMessageConsumer} instance to run in the
+	 * pipeline
+	 * @param <T> the message payload type
+	 * @return a builder for building a {@link ReactiveMessagePipeline}
+	 */
+	default <T> ReactiveMessagePipelineBuilder<T> messagePipeline(ReactiveMessageConsumer<T> messageConsumer) {
+		return ApiImplementationFactory.createReactiveMessageHandlerPipelineBuilder(messageConsumer);
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/StartAtSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/StartAtSpec.java
new file mode 100644
index 0000000..3cb005c
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/api/StartAtSpec.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.api;
+
+import java.time.Instant;
+
+import org.apache.pulsar.client.api.MessageId;
+
+public abstract class StartAtSpec {
+
+	private static final MessageIdStartAtSpec EARLIEST = ofMessageId(MessageId.earliest, true);
+
+	private static final MessageIdStartAtSpec LATEST = ofMessageId(MessageId.latest, false);
+
+	private static final MessageIdStartAtSpec LATEST_INCLUSIVE = ofMessageId(MessageId.latest, true);
+
+	StartAtSpec() {
+	}
+
+	public static MessageIdStartAtSpec ofEarliest() {
+		return EARLIEST;
+	}
+
+	public static MessageIdStartAtSpec ofLatest() {
+		return LATEST;
+	}
+
+	public static MessageIdStartAtSpec ofLatestInclusive() {
+		return LATEST_INCLUSIVE;
+	}
+
+	public static MessageIdStartAtSpec ofMessageId(MessageId messageId, boolean inclusive) {
+		return new MessageIdStartAtSpec(messageId, inclusive);
+	}
+
+	public static MessageIdStartAtSpec ofMessageId(MessageId messageId) {
+		return ofMessageId(messageId, false);
+	}
+
+	public static InstantStartAtSpec ofInstant(Instant instant) {
+		return new InstantStartAtSpec(instant);
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/ApiImplementationFactory.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/ApiImplementationFactory.java
new file mode 100644
index 0000000..2ab6295
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/ApiImplementationFactory.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.MessageSpecBuilder;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumer;
+import org.apache.pulsar.reactive.client.api.ReactiveMessagePipelineBuilder;
+
+public final class ApiImplementationFactory {
+
+	private ApiImplementationFactory() {
+
+	}
+
+	public static <T> MessageResult<T> acknowledge(MessageId messageId, T value) {
+		return new DefaultMessageResult<>(messageId, true, value);
+	}
+
+	public static <T> MessageResult<T> negativeAcknowledge(MessageId messageId, T value) {
+		return new DefaultMessageResult<T>(messageId, false, value);
+	}
+
+	public static MessageResult<Void> acknowledge(MessageId messageId) {
+		return new EmptyMessageResult(messageId, true);
+	}
+
+	public static MessageResult<Void> negativeAcknowledge(MessageId messageId) {
+		return new EmptyMessageResult(messageId, false);
+	}
+
+	public static <T> MessageSpecBuilder<T> createMessageSpecBuilder(T value) {
+		return new DefaultMessageSpecBuilder<T>().value(value);
+	}
+
+	public static <T> MessageSpec<T> createValueOnlyMessageSpec(T value) {
+		return new ValueOnlyMessageSpec<>(value);
+	}
+
+	public static <T> ReactiveMessagePipelineBuilder<T> createReactiveMessageHandlerPipelineBuilder(
+			ReactiveMessageConsumer<T> messageConsumer) {
+		return new DefaultReactiveMessagePipelineBuilder<>(messageConsumer);
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageResult.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageResult.java
new file mode 100644
index 0000000..9fd9eff
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageResult.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+
+class DefaultMessageResult<T> implements MessageResult<T> {
+
+	private final MessageId messageId;
+
+	private final boolean acknowledgeMessage;
+
+	private final T value;
+
+	DefaultMessageResult(MessageId messageId, boolean acknowledgeMessage, T value) {
+		this.messageId = messageId;
+		this.acknowledgeMessage = acknowledgeMessage;
+		this.value = value;
+	}
+
+	@Override
+	public MessageId getMessageId() {
+		return this.messageId;
+	}
+
+	@Override
+	public boolean isAcknowledgeMessage() {
+		return this.acknowledgeMessage;
+	}
+
+	@Override
+	public T getValue() {
+		return this.value;
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageSpec.java
new file mode 100644
index 0000000..151817d
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageSpec.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.client.api.TypedMessageBuilder;
+
+class DefaultMessageSpec<T> implements InternalMessageSpec<T> {
+
+	private final String key;
+
+	private final byte[] orderingKey;
+
+	private final byte[] keyBytes;
+
+	private final T value;
+
+	private final Map<String, String> properties;
+
+	private final Long eventTime;
+
+	private final Long sequenceId;
+
+	private final List<String> replicationClusters;
+
+	private final boolean disableReplication;
+
+	private final Long deliverAt;
+
+	private final Long deliverAfterDelay;
+
+	private final TimeUnit deliverAfterUnit;
+
+	DefaultMessageSpec(String key, byte[] orderingKey, byte[] keyBytes, T value, Map<String, String> properties,
+			Long eventTime, Long sequenceId, List<String> replicationClusters, boolean disableReplication,
+			Long deliverAt, Long deliverAfterDelay, TimeUnit deliverAfterUnit) {
+		this.key = key;
+		this.orderingKey = orderingKey;
+		this.keyBytes = keyBytes;
+		this.value = value;
+		this.properties = properties;
+		this.eventTime = eventTime;
+		this.sequenceId = sequenceId;
+		this.replicationClusters = replicationClusters;
+		this.disableReplication = disableReplication;
+		this.deliverAt = deliverAt;
+		this.deliverAfterDelay = deliverAfterDelay;
+		this.deliverAfterUnit = deliverAfterUnit;
+	}
+
+	@Override
+	public void configure(TypedMessageBuilder<T> typedMessageBuilder) {
+		if (this.key != null) {
+			typedMessageBuilder.key(this.key);
+		}
+		if (this.orderingKey != null) {
+			typedMessageBuilder.orderingKey(this.orderingKey);
+		}
+		if (this.keyBytes != null) {
+			typedMessageBuilder.keyBytes(this.keyBytes);
+		}
+		typedMessageBuilder.value(this.value);
+		if (this.properties != null) {
+			typedMessageBuilder.properties(this.properties);
+		}
+		if (this.eventTime != null) {
+			typedMessageBuilder.eventTime(this.eventTime);
+		}
+		if (this.sequenceId != null) {
+			typedMessageBuilder.sequenceId(this.sequenceId);
+		}
+		if (this.replicationClusters != null) {
+			typedMessageBuilder.replicationClusters(this.replicationClusters);
+		}
+		if (this.disableReplication) {
+			typedMessageBuilder.disableReplication();
+		}
+		if (this.deliverAt != null) {
+			typedMessageBuilder.deliverAt(this.deliverAt);
+		}
+		if (this.deliverAfterDelay != null) {
+			typedMessageBuilder.deliverAfter(this.deliverAfterDelay, this.deliverAfterUnit);
+		}
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageSpecBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageSpecBuilder.java
new file mode 100644
index 0000000..0b4652f
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultMessageSpecBuilder.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+import org.apache.pulsar.reactive.client.api.MessageSpecBuilder;
+
+class DefaultMessageSpecBuilder<T> implements MessageSpecBuilder<T> {
+
+	private String key;
+
+	private byte[] orderingKey;
+
+	private byte[] keyBytes;
+
+	private T value;
+
+	private Map<String, String> properties;
+
+	private Long eventTime;
+
+	private Long sequenceId;
+
+	private List<String> replicationClusters;
+
+	private boolean disableReplication;
+
+	private Long deliverAt;
+
+	private Long deliverAfterDelay;
+
+	private TimeUnit deliverAfterUnit;
+
+	@Override
+	public MessageSpecBuilder<T> key(String key) {
+		this.key = key;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> keyBytes(byte[] key) {
+		this.keyBytes = key;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> orderingKey(byte[] orderingKey) {
+		this.orderingKey = orderingKey;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> value(T value) {
+		this.value = value;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> property(String name, String value) {
+		if (this.properties == null) {
+			this.properties = new HashMap<>();
+		}
+		this.properties.put(name, value);
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> properties(Map<String, String> properties) {
+		if (this.properties == null) {
+			this.properties = new HashMap<>();
+		}
+		this.properties.putAll(properties);
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> eventTime(long timestamp) {
+		this.eventTime = timestamp;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> sequenceId(long sequenceId) {
+		this.sequenceId = sequenceId;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> replicationClusters(List<String> clusters) {
+		this.replicationClusters = new ArrayList<>(clusters);
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> disableReplication() {
+		this.disableReplication = true;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> deliverAt(long timestamp) {
+		this.deliverAt = timestamp;
+		return this;
+	}
+
+	@Override
+	public MessageSpecBuilder<T> deliverAfter(long delay, TimeUnit unit) {
+		this.deliverAfterDelay = delay;
+		this.deliverAfterUnit = unit;
+		return this;
+	}
+
+	@Override
+	public MessageSpec<T> build() {
+		return new DefaultMessageSpec<T>(this.key, this.orderingKey, this.keyBytes, this.value, this.properties,
+				this.eventTime, this.sequenceId, this.replicationClusters, this.disableReplication, this.deliverAt,
+				this.deliverAfterDelay, this.deliverAfterUnit);
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultReactiveMessagePipeline.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultReactiveMessagePipeline.java
new file mode 100644
index 0000000..bf7d265
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultReactiveMessagePipeline.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.reactive.client.api.MessageGroupingFunction;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumer;
+import org.apache.pulsar.reactive.client.api.ReactiveMessagePipeline;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import reactor.util.context.Context;
+import reactor.util.retry.Retry;
+
+class DefaultReactiveMessagePipeline<T> implements ReactiveMessagePipeline {
+
+	private static final Logger LOG = LoggerFactory.getLogger(DefaultReactiveMessagePipeline.class);
+
+	private static final String INFLIGHT_LIMITER_CONTEXT_KEY = DefaultReactiveMessagePipelineBuilder.class.getName()
+			+ ".INFLIGHT_LIMITER_CONTEXT_KEY";
+
+	private final AtomicReference<Disposable> killSwitch = new AtomicReference<>();
+
+	private final Mono<Void> pipeline;
+
+	private final Function<Message<T>, Mono<Void>> messageHandler;
+
+	private final BiConsumer<Message<T>, Throwable> errorLogger;
+
+	private final Retry pipelineRetrySpec;
+
+	private final Duration handlingTimeout;
+
+	private final Function<Flux<Message<T>>, Flux<MessageResult<Void>>> streamingMessageHandler;
+
+	private final int concurrency;
+
+	private final int maxInflight;
+
+	private final MessageGroupingFunction groupingFunction;
+
+	DefaultReactiveMessagePipeline(ReactiveMessageConsumer<T> messageConsumer,
+			Function<Message<T>, Mono<Void>> messageHandler, BiConsumer<Message<T>, Throwable> errorLogger,
+			Retry pipelineRetrySpec, Duration handlingTimeout, Function<Mono<Void>, Mono<Void>> transformer,
+			Function<Flux<Message<T>>, Flux<MessageResult<Void>>> streamingMessageHandler,
+			MessageGroupingFunction groupingFunction, int concurrency, int maxInflight) {
+		this.messageHandler = messageHandler;
+		this.errorLogger = errorLogger;
+		this.pipelineRetrySpec = pipelineRetrySpec;
+		this.handlingTimeout = handlingTimeout;
+		this.streamingMessageHandler = streamingMessageHandler;
+		this.groupingFunction = groupingFunction;
+		this.concurrency = concurrency;
+		this.maxInflight = maxInflight;
+		this.pipeline = messageConsumer.consumeMessages(this::createMessageConsumer).then().transform(transformer)
+				.transform(this::decoratePipeline);
+	}
+
+	private Mono<Void> decorateMessageHandler(Mono<Void> messageHandler) {
+		if (this.handlingTimeout != null) {
+			messageHandler = messageHandler.timeout(this.handlingTimeout);
+		}
+		if (this.maxInflight > 0) {
+			messageHandler = messageHandler.transformDeferredContextual((original, context) -> {
+				InflightLimiter inflightLimiter = context.get(INFLIGHT_LIMITER_CONTEXT_KEY);
+				return inflightLimiter.transform(original);
+			});
+		}
+		return messageHandler;
+	}
+
+	private Mono<Void> decoratePipeline(Mono<Void> pipeline) {
+		if (this.maxInflight > 0) {
+			Mono<Void> finalPipeline = pipeline;
+			pipeline = Mono.using(() -> new InflightLimiter(this.maxInflight),
+					(inflightLimiter) -> (finalPipeline)
+							.contextWrite(Context.of(INFLIGHT_LIMITER_CONTEXT_KEY, inflightLimiter)),
+					InflightLimiter::dispose);
+		}
+		if (this.pipelineRetrySpec != null) {
+			return pipeline.retryWhen(this.pipelineRetrySpec);
+		}
+		else {
+			return pipeline;
+		}
+	}
+
+	private Flux<MessageResult<Void>> createMessageConsumer(Flux<Message<T>> messageFlux) {
+		if (this.messageHandler != null) {
+			if (this.streamingMessageHandler != null) {
+				throw new IllegalStateException(
+						"messageHandler and streamingMessageHandler cannot be set at the same time.");
+			}
+			if (this.concurrency > 1) {
+				if (this.groupingFunction != null) {
+					return GroupOrderedMessageProcessors.processGroupsInOrderConcurrently(messageFlux,
+							this.groupingFunction, this::handleMessage, Schedulers.parallel(), this.concurrency);
+				}
+				else {
+					return messageFlux.flatMap((message) -> handleMessage(message).subscribeOn(Schedulers.parallel()),
+							this.concurrency);
+				}
+			}
+			else {
+				return messageFlux.concatMap(this::handleMessage);
+			}
+		}
+		else {
+			return Objects.requireNonNull(this.streamingMessageHandler,
+					"streamingMessageHandler or messageHandler must be set").apply(messageFlux);
+		}
+	}
+
+	private Mono<MessageResult<Void>> handleMessage(Message<T> message) {
+		return this.messageHandler.apply(message).transform(this::decorateMessageHandler)
+				.thenReturn(MessageResult.acknowledge(message.getMessageId())).onErrorResume((throwable) -> {
+					if (this.errorLogger != null) {
+						try {
+							this.errorLogger.accept(message, throwable);
+						}
+						catch (Exception ex) {
+							LOG.error("Error in calling error logger", ex);
+						}
+					}
+					else {
+						LOG.error("Message handling for message id {} failed.", message.getMessageId(), throwable);
+					}
+					// TODO: nack doesn't work for batch messages due to Pulsar bugs
+					return Mono.just(MessageResult.negativeAcknowledge(message.getMessageId()));
+				});
+	}
+
+	@Override
+	public ReactiveMessagePipeline start() {
+		if (this.killSwitch.get() != null) {
+			throw new IllegalStateException("Message handler is already running.");
+		}
+		Disposable disposable = this.pipeline.subscribe(null, this::logError, this::logUnexpectedCompletion);
+		if (!this.killSwitch.compareAndSet(null, disposable)) {
+			disposable.dispose();
+			throw new IllegalStateException("Message handler was already running.");
+		}
+		return this;
+	}
+
+	private void logError(Throwable throwable) {
+		LOG.error("ReactiveMessageHandler was unexpectedly terminated.", throwable);
+	}
+
+	private void logUnexpectedCompletion() {
+		if (isRunning()) {
+			LOG.error("ReactiveMessageHandler was unexpectedly completed.");
+		}
+	}
+
+	@Override
+	public ReactiveMessagePipeline stop() {
+		Disposable disposable = this.killSwitch.getAndSet(null);
+		if (disposable != null) {
+			disposable.dispose();
+		}
+		return this;
+	}
+
+	@Override
+	public boolean isRunning() {
+		return this.killSwitch.get() != null;
+	}
+
+	@Override
+	public void close() throws Exception {
+		stop();
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultReactiveMessagePipelineBuilder.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultReactiveMessagePipelineBuilder.java
new file mode 100644
index 0000000..e92f683
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/DefaultReactiveMessagePipelineBuilder.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.ServiceLoader;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.reactive.client.api.MessageGroupingFunction;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumer;
+import org.apache.pulsar.reactive.client.api.ReactiveMessagePipeline;
+import org.apache.pulsar.reactive.client.api.ReactiveMessagePipelineBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.util.retry.Retry;
+
+class DefaultReactiveMessagePipelineBuilder<T>
+		implements ReactiveMessagePipelineBuilder.ConcurrentOneByOneMessagePipelineBuilder<T> {
+
+	private static final MessageGroupingFunction KEY_ORDERED_GROUPING_FUNCTION;
+
+	static {
+		Iterator<MessageGroupingFunction> groupingFunctionIterator = ServiceLoader.load(MessageGroupingFunction.class)
+				.iterator();
+		if (groupingFunctionIterator.hasNext()) {
+			KEY_ORDERED_GROUPING_FUNCTION = groupingFunctionIterator.next();
+		}
+		else {
+			KEY_ORDERED_GROUPING_FUNCTION = null;
+		}
+	}
+
+	private final Logger LOG = LoggerFactory.getLogger(DefaultReactiveMessagePipelineBuilder.class);
+
+	private final ReactiveMessageConsumer<T> messageConsumer;
+
+	private Function<Message<T>, Mono<Void>> messageHandler;
+
+	private BiConsumer<Message<T>, Throwable> errorLogger;
+
+	private Retry pipelineRetrySpec = Retry.backoff(Long.MAX_VALUE, Duration.ofSeconds(5))
+			.maxBackoff(Duration.ofMinutes(1))
+			.doBeforeRetry((retrySignal) -> this.LOG.error(
+					"Message handler pipeline failed." + "Retrying to start message handler pipeline, retry #{}",
+					retrySignal.totalRetriesInARow(), retrySignal.failure()));
+
+	private Duration handlingTimeout = Duration.ofSeconds(120);
+
+	private Function<Mono<Void>, Mono<Void>> transformer = Function.identity();
+
+	private Function<Flux<Message<T>>, Flux<MessageResult<Void>>> streamingMessageHandler;
+
+	private int concurrency;
+
+	private int maxInflight;
+
+	private MessageGroupingFunction groupingFunction;
+
+	DefaultReactiveMessagePipelineBuilder(ReactiveMessageConsumer<T> messageConsumer) {
+		this.messageConsumer = messageConsumer;
+	}
+
+	@Override
+	public OneByOneMessagePipelineBuilder<T> messageHandler(Function<Message<T>, Mono<Void>> messageHandler) {
+		this.messageHandler = messageHandler;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessagePipelineBuilder<T> streamingMessageHandler(
+			Function<Flux<Message<T>>, Flux<MessageResult<Void>>> streamingMessageHandler) {
+		this.streamingMessageHandler = streamingMessageHandler;
+		return this;
+	}
+
+	@Override
+	public OneByOneMessagePipelineBuilder<T> errorLogger(BiConsumer<Message<T>, Throwable> errorLogger) {
+		this.errorLogger = errorLogger;
+		return this;
+	}
+
+	@Override
+	public ConcurrentOneByOneMessagePipelineBuilder<T> concurrent() {
+		return this;
+	}
+
+	@Override
+	public ConcurrentOneByOneMessagePipelineBuilder<T> useKeyOrderedProcessing() {
+		Objects.requireNonNull(KEY_ORDERED_GROUPING_FUNCTION,
+				"MessageGroupingFunction to use for key ordered processing wasn't found by service loader.");
+		groupOrderedProcessing(KEY_ORDERED_GROUPING_FUNCTION);
+		return this;
+	}
+
+	@Override
+	public ConcurrentOneByOneMessagePipelineBuilder<T> groupOrderedProcessing(
+			MessageGroupingFunction groupingFunction) {
+		this.groupingFunction = groupingFunction;
+		return this;
+	}
+
+	@Override
+	public ConcurrentOneByOneMessagePipelineBuilder<T> concurrency(int concurrency) {
+		this.concurrency = concurrency;
+		return this;
+	}
+
+	@Override
+	public ConcurrentOneByOneMessagePipelineBuilder<T> maxInflight(int maxInflight) {
+		this.maxInflight = maxInflight;
+		return this;
+	}
+
+	@Override
+	public OneByOneMessagePipelineBuilder<T> handlingTimeout(Duration handlingTimeout) {
+		this.handlingTimeout = handlingTimeout;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessagePipelineBuilder<T> pipelineRetrySpec(Retry pipelineRetrySpec) {
+		this.pipelineRetrySpec = pipelineRetrySpec;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessagePipelineBuilder<T> transformPipeline(Function<Mono<Void>, Mono<Void>> transformer) {
+		this.transformer = transformer;
+		return this;
+	}
+
+	@Override
+	public ReactiveMessagePipeline build() {
+		return new DefaultReactiveMessagePipeline(this.messageConsumer, this.messageHandler, this.errorLogger,
+				this.pipelineRetrySpec, this.handlingTimeout, this.transformer, this.streamingMessageHandler,
+				this.groupingFunction, this.concurrency, this.maxInflight);
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/EmptyMessageResult.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/EmptyMessageResult.java
new file mode 100644
index 0000000..9c21d66
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/EmptyMessageResult.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import org.apache.pulsar.client.api.MessageId;
+import org.apache.pulsar.reactive.client.api.MessageResult;
+
+class EmptyMessageResult implements MessageResult<Void> {
+
+	private final MessageId messageId;
+
+	private final boolean acknowledgeMessage;
+
+	EmptyMessageResult(MessageId messageId, boolean acknowledgeMessage) {
+		this.messageId = messageId;
+		this.acknowledgeMessage = acknowledgeMessage;
+	}
+
+	@Override
+	public MessageId getMessageId() {
+		return this.messageId;
+	}
+
+	@Override
+	public boolean isAcknowledgeMessage() {
+		return this.acknowledgeMessage;
+	}
+
+	@Override
+	public Void getValue() {
+		return null;
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/GroupOrderedMessageProcessors.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/GroupOrderedMessageProcessors.java
new file mode 100644
index 0000000..b0aeff1
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/GroupOrderedMessageProcessors.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import java.util.function.Function;
+
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.reactive.client.api.MessageGroupingFunction;
+import org.reactivestreams.Publisher;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.GroupedFlux;
+import reactor.core.scheduler.Scheduler;
+import reactor.util.concurrent.Queues;
+
+/**
+ * Functions for implementing In-order parallel processing for Pulsar messages using
+ * Project Reactor.
+ *
+ * A processing group is resolved for each message based on the message's key. The message
+ * flux is split into group fluxes based on the processing group. Each group flux is
+ * processes messages in order (one-by-one). Multiple group fluxes are processed in
+ * parallel.
+ *
+ * @author Lari Hotari
+ */
+public final class GroupOrderedMessageProcessors {
+
+	private GroupOrderedMessageProcessors() {
+
+	}
+
+	/**
+	 * Splits the flux of messages by message key into the given number of groups.
+	 * @param <T> message payload type
+	 * @param messageFlux flux of messages
+	 * @param groupingFunction function to use for resolving the group for a message
+	 * @param numberOfGroups number of processing groups
+	 * @return the grouped flux of messages
+	 */
+	public static <T> Flux<GroupedFlux<Integer, Message<T>>> groupByProcessingGroup(Flux<Message<T>> messageFlux,
+			MessageGroupingFunction groupingFunction, int numberOfGroups) {
+		return messageFlux.groupBy((message) -> groupingFunction.resolveProcessingGroup(message, numberOfGroups),
+				Math.max(Queues.XS_BUFFER_SIZE, numberOfGroups));
+	}
+
+	/**
+	 * Processes the messages concurrently with the targeted concurrency. Uses ".flatMap"
+	 * in the implementation
+	 * @param <T> message payload type
+	 * @param <R> message handler's resulting type
+	 * @param messageFlux the flux of messages
+	 * @param groupingFunction function to use for resolving the group for a message
+	 * @param messageHandler message handler function
+	 * @param scheduler scheduler to use for subscribing to inner publishers
+	 * @param concurrency targeted concurrency level
+	 * @return flux of message handler results
+	 */
+	public static <T, R> Flux<R> processGroupsInOrderConcurrently(Flux<Message<T>> messageFlux,
+			MessageGroupingFunction groupingFunction,
+			Function<? super Message<T>, ? extends Publisher<? extends R>> messageHandler, Scheduler scheduler,
+			int concurrency) {
+		return groupByProcessingGroup(messageFlux, groupingFunction, concurrency)
+				.flatMap((groupedFlux) -> groupedFlux.publishOn(scheduler).concatMap(messageHandler), concurrency);
+	}
+
+	/**
+	 * Processes the messages in parallel with the targeted parallelism. Uses ".parallel"
+	 * in the implementation
+	 * @param <T> message payload type
+	 * @param <R> message handler's resulting type
+	 * @param messageFlux the flux of messages
+	 * @param groupingFunction function to use for resolving the group for a message
+	 * @param messageHandler message handler function
+	 * @param scheduler scheduler to use for subscribing to inner publishers
+	 * @param parallelism targeted level of parallelism
+	 * @return flux of message handler results
+	 */
+	public static <T, R> Flux<R> processGroupsInOrderInParallel(Flux<Message<T>> messageFlux,
+			MessageGroupingFunction groupingFunction,
+			Function<? super Message<T>, ? extends Publisher<? extends R>> messageHandler, Scheduler scheduler,
+			int parallelism) {
+		return groupByProcessingGroup(messageFlux, groupingFunction, parallelism).parallel(parallelism).runOn(scheduler)
+				.flatMap((groupedFlux) -> groupedFlux.concatMap(messageHandler)).sequential();
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/InflightLimiter.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/InflightLimiter.java
new file mode 100644
index 0000000..0f1f95c
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/InflightLimiter.java
@@ -0,0 +1,294 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.jctools.queues.MpmcArrayQueue;
+import org.reactivestreams.Publisher;
+import org.reactivestreams.Subscription;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.CoreSubscriber;
+import reactor.core.publisher.BaseSubscriber;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.FluxOperator;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.MonoOperator;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+import reactor.util.context.Context;
+
+public class InflightLimiter implements PublisherTransformer {
+
+	/** Default limit for pending Reactive Stream subscriptions. */
+	public static final int DEFAULT_MAX_PENDING_SUBSCRIPTIONS = 1024;
+
+	private static final Logger LOG = LoggerFactory.getLogger(InflightLimiter.class);
+
+	private final MpmcArrayQueue<InflightLimiterSubscriber<?>> pendingSubscriptions;
+
+	private final AtomicInteger inflight = new AtomicInteger();
+
+	private final AtomicInteger activeSubscriptions = new AtomicInteger();
+
+	private final int maxInflight;
+
+	private final int expectedSubscriptionsInflight;
+
+	private final Scheduler.Worker triggerNextWorker;
+
+	public InflightLimiter(int maxInflight) {
+		this(maxInflight, maxInflight, Schedulers.single(), DEFAULT_MAX_PENDING_SUBSCRIPTIONS);
+	}
+
+	public InflightLimiter(int maxInflight, int expectedSubscriptionsInflight, Scheduler triggerNextScheduler,
+			int maxPendingSubscriptions) {
+		this.maxInflight = maxInflight;
+		this.expectedSubscriptionsInflight = expectedSubscriptionsInflight;
+		this.triggerNextWorker = triggerNextScheduler.createWorker();
+		if (expectedSubscriptionsInflight > maxInflight) {
+			throw new IllegalArgumentException("maxSubscriptionInflight must be equal or less than maxInflight.");
+		}
+		this.pendingSubscriptions = new MpmcArrayQueue<>(maxPendingSubscriptions);
+	}
+
+	@Override
+	public <T> Publisher<T> transform(Publisher<T> publisher) {
+		if (publisher instanceof Mono<?>) {
+			return createOperator((Mono) publisher);
+		}
+		else {
+			return createOperator(Flux.from(publisher));
+		}
+	}
+
+	public <I> Flux<I> createOperator(Flux<I> source) {
+		return new FluxOperator<I, I>(source) {
+			@Override
+			public void subscribe(CoreSubscriber<? super I> actual) {
+				handleSubscribe(this.source, actual);
+			}
+		};
+	}
+
+	public <I> Mono<I> createOperator(Mono<I> source) {
+		return new MonoOperator<I, I>(source) {
+			@Override
+			public void subscribe(CoreSubscriber<? super I> actual) {
+				handleSubscribe(this.source, actual);
+			}
+		};
+	}
+
+	<I> void handleSubscribe(Publisher<I> source, CoreSubscriber<? super I> actual) {
+		this.activeSubscriptions.incrementAndGet();
+		InflightLimiterSubscriber<I> subscriber = new InflightLimiterSubscriber<I>(actual, source);
+		actual.onSubscribe(subscriber.getSubscription());
+	}
+
+	void maybeTriggerNext() {
+		if (!this.triggerNextWorker.isDisposed()) {
+			this.triggerNextWorker.schedule(() -> {
+				int remainingSubscriptions = this.pendingSubscriptions.size();
+				while (this.inflight.get() < this.maxInflight && remainingSubscriptions-- > 0) {
+					InflightLimiterSubscriber<?> subscriber = this.pendingSubscriptions.poll();
+					if (subscriber != null) {
+						if (!subscriber.isDisposed()) {
+							subscriber.requestMore();
+						}
+					}
+					else {
+						break;
+					}
+				}
+			});
+		}
+	}
+
+	void scheduleSubscribed(InflightLimiterSubscriber<?> subscriber) {
+		if (!this.triggerNextWorker.isDisposed()) {
+			this.triggerNextWorker.schedule(() -> {
+				if (!subscriber.isDisposed()) {
+					subscriber.requestMore();
+				}
+			});
+		}
+	}
+
+	@Override
+	public void dispose() {
+		this.triggerNextWorker.dispose();
+		this.pendingSubscriptions.drain(InflightLimiterSubscriber::cancel);
+	}
+
+	@Override
+	public boolean isDisposed() {
+		return this.triggerNextWorker.isDisposed();
+	}
+
+	private enum InflightLimiterSubscriberState {
+
+		INITIAL, SUBSCRIBING, SUBSCRIBED, REQUESTING
+
+	}
+
+	private class InflightLimiterSubscriber<I> extends BaseSubscriber<I> {
+
+		private final CoreSubscriber<? super I> actual;
+
+		private final Publisher<I> source;
+
+		private final AtomicLong requestedDemand = new AtomicLong();
+
+		private final AtomicReference<InflightLimiterSubscriberState> state = new AtomicReference<>(
+				InflightLimiterSubscriberState.INITIAL);
+
+		private final AtomicInteger inflightForSubscription = new AtomicInteger();
+
+		private final Subscription subscription = new Subscription() {
+			@Override
+			public void request(long n) {
+				InflightLimiterSubscriber.this.requestedDemand.addAndGet(n);
+				maybeAddToPending();
+				maybeTriggerNext();
+			}
+
+			@Override
+			public void cancel() {
+				InflightLimiterSubscriber.this.cancel();
+			}
+		};
+
+		InflightLimiterSubscriber(CoreSubscriber<? super I> actual, Publisher<I> source) {
+			this.actual = actual;
+			this.source = source;
+		}
+
+		@Override
+		public Context currentContext() {
+			return this.actual.currentContext();
+		}
+
+		@Override
+		protected void hookOnSubscribe(Subscription subscription) {
+			if (this.state.compareAndSet(InflightLimiterSubscriberState.SUBSCRIBING,
+					InflightLimiterSubscriberState.SUBSCRIBED)) {
+				scheduleSubscribed(this);
+			}
+		}
+
+		@Override
+		protected void hookOnNext(I value) {
+			this.actual.onNext(value);
+			InflightLimiter.this.inflight.decrementAndGet();
+			this.inflightForSubscription.decrementAndGet();
+			maybeAddToPending();
+			maybeTriggerNext();
+		}
+
+		@Override
+		protected void hookOnComplete() {
+			InflightLimiter.this.activeSubscriptions.decrementAndGet();
+			this.actual.onComplete();
+			clearInflight();
+			maybeTriggerNext();
+		}
+
+		private void clearInflight() {
+			InflightLimiter.this.inflight.addAndGet(-this.inflightForSubscription.getAndSet(0));
+		}
+
+		@Override
+		protected void hookOnError(Throwable throwable) {
+			InflightLimiter.this.activeSubscriptions.decrementAndGet();
+			this.actual.onError(throwable);
+			clearInflight();
+			maybeTriggerNext();
+		}
+
+		@Override
+		protected void hookOnCancel() {
+			InflightLimiter.this.activeSubscriptions.decrementAndGet();
+			clearInflight();
+			this.requestedDemand.set(0);
+			maybeTriggerNext();
+		}
+
+		Subscription getSubscription() {
+			return this.subscription;
+		}
+
+		void requestMore() {
+			if (this.state.get() == InflightLimiterSubscriberState.SUBSCRIBED || (this.requestedDemand.get() > 0
+					&& this.inflightForSubscription.get() <= InflightLimiter.this.expectedSubscriptionsInflight / 2
+					&& InflightLimiter.this.inflight.get() < InflightLimiter.this.maxInflight)) {
+				if (this.state.compareAndSet(InflightLimiterSubscriberState.INITIAL,
+						InflightLimiterSubscriberState.SUBSCRIBING)) {
+					// consume one slot for the subscription, since the first element
+					// might already be in flight
+					// when a CompletableFuture is mapped to a Mono
+					InflightLimiter.this.inflight.incrementAndGet();
+					this.requestedDemand.decrementAndGet();
+					this.inflightForSubscription.incrementAndGet();
+					this.source.subscribe(InflightLimiterSubscriber.this);
+				}
+				else if (this.state.get() == InflightLimiterSubscriberState.REQUESTING
+						|| this.state.get() == InflightLimiterSubscriberState.SUBSCRIBED) {
+					// subscribing changed the values, so adjust back the values on first
+					// call
+					if (this.state.compareAndSet(InflightLimiterSubscriberState.SUBSCRIBED,
+							InflightLimiterSubscriberState.REQUESTING)) {
+						// reverse the slot reservation made when transitioning from
+						// INITIAL to SUBSCRIBING
+						InflightLimiter.this.inflight.decrementAndGet();
+						this.requestedDemand.incrementAndGet();
+						this.inflightForSubscription.decrementAndGet();
+					}
+					long maxRequest = Math
+							.max(Math.min(
+									Math.min(
+											Math.min(this.requestedDemand.get(),
+													InflightLimiter.this.maxInflight
+															- InflightLimiter.this.inflight.get()),
+											InflightLimiter.this.expectedSubscriptionsInflight
+													- this.inflightForSubscription.get()),
+									InflightLimiter.this.maxInflight
+											/ Math.max(InflightLimiter.this.activeSubscriptions.get(), 1)),
+									1);
+					InflightLimiter.this.inflight.addAndGet((int) maxRequest);
+					this.requestedDemand.addAndGet(-maxRequest);
+					this.inflightForSubscription.addAndGet((int) maxRequest);
+					request(maxRequest);
+				}
+			}
+			else {
+				maybeAddToPending();
+			}
+		}
+
+		void maybeAddToPending() {
+			if (this.requestedDemand.get() > 0 && !isDisposed() && this.inflightForSubscription.get() == 0) {
+				InflightLimiter.this.pendingSubscriptions.add(this);
+			}
+		}
+
+	}
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/InternalMessageSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/InternalMessageSpec.java
new file mode 100644
index 0000000..cd7c8e8
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/InternalMessageSpec.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import org.apache.pulsar.client.api.TypedMessageBuilder;
+import org.apache.pulsar.reactive.client.api.MessageSpec;
+
+public interface InternalMessageSpec<T> extends MessageSpec<T> {
+
+	void configure(TypedMessageBuilder<T> typedMessageBuilder);
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/PublisherTransformer.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/PublisherTransformer.java
new file mode 100644
index 0000000..98f4415
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/PublisherTransformer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import org.reactivestreams.Publisher;
+import reactor.core.Disposable;
+
+public interface PublisherTransformer extends Disposable {
+
+	static PublisherTransformer identity() {
+		return new PublisherTransformer() {
+			@Override
+			public void dispose() {
+			}
+
+			@Override
+			public <T> Publisher<T> transform(Publisher<T> publisher) {
+				return publisher;
+			}
+		};
+	}
+
+	<T> Publisher<T> transform(Publisher<T> publisher);
+
+}
diff --git a/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/ValueOnlyMessageSpec.java b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/ValueOnlyMessageSpec.java
new file mode 100644
index 0000000..a65f52a
--- /dev/null
+++ b/pulsar-client-reactive-api/src/main/java/org/apache/pulsar/reactive/client/internal/api/ValueOnlyMessageSpec.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.internal.api;
+
+import org.apache.pulsar.client.api.TypedMessageBuilder;
+
+class ValueOnlyMessageSpec<T> implements InternalMessageSpec<T> {
+
+	private final T value;
+
+	ValueOnlyMessageSpec(T value) {
+		this.value = value;
+	}
+
+	@Override
+	public void configure(TypedMessageBuilder<T> typedMessageBuilder) {
+		typedMessageBuilder.value(this.value);
+	}
+
+}
diff --git a/pulsar-client-reactive-producer-cache-caffeine/build.gradle b/pulsar-client-reactive-producer-cache-caffeine/build.gradle
new file mode 100644
index 0000000..c3e0058
--- /dev/null
+++ b/pulsar-client-reactive-producer-cache-caffeine/build.gradle
@@ -0,0 +1,16 @@
+plugins {
+	id 'pulsar-client-reactive.codestyle-conventions'
+	id 'pulsar-client-reactive.library-conventions'
+}
+
+dependencies {
+	api project(':pulsar-client-reactive-adapter')
+	api libs.caffeine
+	testImplementation libs.junit.jupiter
+	testImplementation libs.assertj.core
+	testImplementation libs.reactor.test
+}
+
+description = "Caffeine implementation of producer cache"
+
+
diff --git a/pulsar-client-reactive-producer-cache-caffeine/src/main/java/org/apache/pulsar/reactive/client/producercache/CaffeineProducerCacheProvider.java b/pulsar-client-reactive-producer-cache-caffeine/src/main/java/org/apache/pulsar/reactive/client/producercache/CaffeineProducerCacheProvider.java
new file mode 100644
index 0000000..d7b3a8c
--- /dev/null
+++ b/pulsar-client-reactive-producer-cache-caffeine/src/main/java/org/apache/pulsar/reactive/client/producercache/CaffeineProducerCacheProvider.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.producercache;
+
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+import com.github.benmanes.caffeine.cache.AsyncCache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.CaffeineSpec;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.github.benmanes.caffeine.cache.Scheduler;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+import reactor.core.scheduler.Schedulers;
+
+public class CaffeineProducerCacheProvider implements ProducerCacheProvider {
+
+	final AsyncCache<Object, Object> cache;
+
+	public CaffeineProducerCacheProvider() {
+		this(Caffeine.newBuilder().expireAfterAccess(Duration.ofMinutes(1)).expireAfterWrite(Duration.ofMinutes(10))
+				.maximumSize(1000));
+	}
+
+	public CaffeineProducerCacheProvider(CaffeineSpec caffeineSpec) {
+		this(Caffeine.from(caffeineSpec));
+	}
+
+	public CaffeineProducerCacheProvider(Caffeine<Object, Object> caffeineBuilder) {
+		this.cache = caffeineBuilder.scheduler(Scheduler.systemScheduler())
+				.executor(Schedulers.boundedElastic()::schedule).removalListener(this::onRemoval).buildAsync();
+	}
+
+	private void onRemoval(Object key, Object entry, RemovalCause cause) {
+		if (entry instanceof AutoCloseable) {
+			try {
+				((AutoCloseable) entry).close();
+			}
+			catch (Exception ex) {
+				throw new RuntimeException(ex);
+			}
+		}
+	}
+
+	public void close() {
+		this.cache.synchronous().invalidateAll();
+	}
+
+	@Override
+	public <K, V> CompletableFuture<V> getOrCreateCachedEntry(K key,
+			Function<K, CompletableFuture<V>> createEntryFunction) {
+		return (CompletableFuture<V>) this.cache.get(key,
+				(__, ___) -> (CompletableFuture) createEntryFunction.apply(key));
+	}
+
+}
diff --git a/pulsar-client-reactive-producer-cache-caffeine/src/main/java/org/apache/pulsar/reactive/client/producercache/CaffeineProducerCacheProviderFactory.java b/pulsar-client-reactive-producer-cache-caffeine/src/main/java/org/apache/pulsar/reactive/client/producercache/CaffeineProducerCacheProviderFactory.java
new file mode 100644
index 0000000..733dac3
--- /dev/null
+++ b/pulsar-client-reactive-producer-cache-caffeine/src/main/java/org/apache/pulsar/reactive/client/producercache/CaffeineProducerCacheProviderFactory.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pulsar.reactive.client.producercache;
+
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider;
+import org.apache.pulsar.reactive.client.adapter.ProducerCacheProviderFactory;
+
+public class CaffeineProducerCacheProviderFactory implements ProducerCacheProviderFactory {
+
+	@Override
+	public ProducerCacheProvider get() {
+		return new CaffeineProducerCacheProvider();
+	}
+
+}
diff --git a/pulsar-client-reactive-producer-cache-caffeine/src/main/resources/META-INF/services/org.apache.pulsar.reactive.client.adapter.ProducerCacheProviderFactory b/pulsar-client-reactive-producer-cache-caffeine/src/main/resources/META-INF/services/org.apache.pulsar.reactive.client.adapter.ProducerCacheProviderFactory
new file mode 100644
index 0000000..b818b31
--- /dev/null
+++ b/pulsar-client-reactive-producer-cache-caffeine/src/main/resources/META-INF/services/org.apache.pulsar.reactive.client.adapter.ProducerCacheProviderFactory
@@ -0,0 +1 @@
+org.apache.pulsar.reactive.client.producercache.CaffeineProducerCacheProviderFactory
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..221fd1c
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+rootProject.name = 'pulsar-client-reactive'
+include 'pulsar-client-reactive-api'
+include 'pulsar-client-reactive-adapter'
+include 'pulsar-client-reactive-producer-cache-caffeine'
+