diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java
new file mode 100644
index 000000000000..7636ce2e17e5
--- /dev/null
+++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/BeanPropertyRowMapper.java
@@ -0,0 +1,463 @@
+/*
+ * Copyright 2002-2023 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.springframework.r2dbc.core;
+
+import java.beans.PropertyDescriptor;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import io.r2dbc.spi.OutParameters;
+import io.r2dbc.spi.OutParametersMetadata;
+import io.r2dbc.spi.Readable;
+import io.r2dbc.spi.ReadableMetadata;
+import io.r2dbc.spi.Row;
+import io.r2dbc.spi.RowMetadata;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+import org.springframework.beans.TypeConverter;
+import org.springframework.beans.TypeMismatchException;
+import org.springframework.core.convert.ConversionService;
+import org.springframework.core.convert.support.DefaultConversionService;
+import org.springframework.dao.InvalidDataAccessApiUsageException;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Mapping {@code Function} implementation that converts an R2DBC {@code Readable}
+ * (a {@code Row} or {@code OutParameters}) into a new instance of the specified mapped
+ * target class. The mapped target class must be a top-level class or {@code static}
+ * nested class, and it must have a default or no-arg constructor.
+ *
+ * 
+ * Readable component values are mapped based on matching the name (as obtained from R2DBC
+ * meta-data) to public setters in the target class for the corresponding properties. The
+ * names are matched either directly or by transforming a name separating the parts with
+ * underscores to the same name using "camel" case.
+ *
+ * 
+ * Mapping is provided for properties in the target class for many common types —
+ * for example: String, boolean, Boolean, byte, Byte, short, Short, int, Integer, long,
+ * Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc.
+ *
+ * 
+ * To facilitate mapping between columns and properties that don't have matching names,
+ * try using column aliases in the SQL statement like
+ * {@code "select fname as first_name from customer"}, where {@code first_name} can be
+ * mapped to a {@code setFirstName(String)} method in the target class.
+ *
+ * 
+ * For a {@code NULL} value read from the database, an attempt will be made to call the
+ * corresponding setter method with {@code null}, but in the case of Java primitives this
+ * will result in a {@link TypeMismatchException} by default. To ignore {@code NULL}
+ * database values for all primitive properties in the target class, set the
+ * {@code primitivesDefaultedForNullValue} flag to {@code true}. See
+ * {@link #setPrimitivesDefaultedForNullValue(boolean)} for details.
+ *
+ * 
+ * If you need to map to a target class which has a data class constructor
+ * — for example, a Java {@code record} or a Kotlin {@code data} class — use
+ * {@link DataClassRowMapper} instead.
+ *
+ * 
+ * Please note that this class is designed to provide convenience rather than high
+ * performance. For best performance, consider using a custom mapping function
+ * implementation.
+ *
+ * @author Simon Baslé
+ * @since 6.1
+ * @param  the result type
+ * @see DataClassRowMapper
+ */
+// Note: this class is adapted from the BeanPropertyRowMapper in spring-jdbc
+public class BeanPropertyRowMapper implements Function {
+
+	/** Logger available to subclasses. */
+	protected final Log logger = LogFactory.getLog(getClass());
+
+	/** The class we are mapping to. */
+	@Nullable
+	private Class mappedClass;
+
+	/** Whether we're strictly validating. */
+	private boolean checkFullyPopulated = false;
+
+	/**
+	 * Whether {@code NULL} database values should be ignored for primitive
+	 * properties in the target class.
+	 * @see #setPrimitivesDefaultedForNullValue(boolean)
+	 */
+	private boolean primitivesDefaultedForNullValue = false;
+
+	/** ConversionService for binding R2DBC values to bean properties. */
+	@Nullable
+	private ConversionService conversionService = DefaultConversionService.getSharedInstance();
+
+	/** Map of the properties we provide mapping for. */
+	@Nullable
+	private Map mappedProperties;
+
+	/** Set of bean property names we provide mapping for. */
+	@Nullable
+	private Set mappedPropertyNames;
+
+	/**
+	 * Create a new {@code BeanPropertyRowMapper}, accepting unpopulated
+	 * properties in the target bean.
+	 * @param mappedClass the class that each row/outParameters should be mapped to
+	 */
+	public BeanPropertyRowMapper(Class mappedClass) {
+		initialize(mappedClass);
+	}
+
+	/**
+	 * Create a new {@code BeanPropertyRowMapper}.
+	 * @param mappedClass the class that each row should be mapped to
+	 * @param checkFullyPopulated whether we're strictly validating that
+	 * all bean properties have been mapped from corresponding database columns or
+	 * out-parameters
+	 */
+	public BeanPropertyRowMapper(Class mappedClass, boolean checkFullyPopulated) {
+		initialize(mappedClass);
+		this.checkFullyPopulated = checkFullyPopulated;
+	}
+
+
+	/**
+	 * Get the class that we are mapping to.
+	 */
+	@Nullable
+	public final Class getMappedClass() {
+		return this.mappedClass;
+	}
+
+	/**
+	 * Set whether we're strictly validating that all bean properties have been mapped
+	 * from corresponding database columns or out-parameters.
+	 * Default is {@code false}, accepting unpopulated properties in the target bean.
+	 */
+	public void setCheckFullyPopulated(boolean checkFullyPopulated) {
+		this.checkFullyPopulated = checkFullyPopulated;
+	}
+
+	/**
+	 * Return whether we're strictly validating that all bean properties have been
+	 * mapped from corresponding database columns or out-parameters.
+	 */
+	public boolean isCheckFullyPopulated() {
+		return this.checkFullyPopulated;
+	}
+
+	/**
+	 * Set whether a {@code NULL} database column or out-parameter value should
+	 * be ignored when mapping to a corresponding primitive property in the target class.
+	 * 
Default is {@code false}, throwing an exception when nulls are mapped
+	 * to Java primitives.
+	 * 
If this flag is set to {@code true} and you use an ignored
+	 * primitive property value from the mapped bean to update the database, the
+	 * value in the database will be changed from {@code NULL} to the current value
+	 * of that primitive property. That value may be the property's initial value
+	 * (potentially Java's default value for the respective primitive type), or
+	 * it may be some other value set for the property in the default constructor
+	 * (or initialization block) or as a side effect of setting some other property
+	 * in the mapped bean.
+	 */
+	public void setPrimitivesDefaultedForNullValue(boolean primitivesDefaultedForNullValue) {
+		this.primitivesDefaultedForNullValue = primitivesDefaultedForNullValue;
+	}
+
+	/**
+	 * Get the value of the {@code primitivesDefaultedForNullValue} flag.
+	 * @see #setPrimitivesDefaultedForNullValue(boolean)
+	 */
+	public boolean isPrimitivesDefaultedForNullValue() {
+		return this.primitivesDefaultedForNullValue;
+	}
+
+	/**
+	 * Set a {@link ConversionService} for binding R2DBC values to bean properties,
+	 * or {@code null} for none.
+	 * 
Default is a {@link DefaultConversionService}. This provides support for
+	 * {@code java.time} conversion and other special types.
+	 * @see #initBeanWrapper(BeanWrapper)
+	 */
+	public void setConversionService(@Nullable ConversionService conversionService) {
+		this.conversionService = conversionService;
+	}
+
+	/**
+	 * Return a {@link ConversionService} for binding R2DBC values to bean properties,
+	 * or {@code null} if none.
+	 */
+	@Nullable
+	public ConversionService getConversionService() {
+		return this.conversionService;
+	}
+
+
+	/**
+	 * Initialize the mapping meta-data for the given class.
+	 * @param mappedClass the mapped class
+	 */
+	protected void initialize(Class mappedClass) {
+		this.mappedClass = mappedClass;
+		this.mappedProperties = new HashMap<>();
+		this.mappedPropertyNames = new HashSet<>();
+
+		for (PropertyDescriptor pd : BeanUtils.getPropertyDescriptors(mappedClass)) {
+			if (pd.getWriteMethod() != null) {
+				String lowerCaseName = lowerCaseName(pd.getName());
+				this.mappedProperties.put(lowerCaseName, pd);
+				String underscoreName = underscoreName(pd.getName());
+				if (!lowerCaseName.equals(underscoreName)) {
+					this.mappedProperties.put(underscoreName, pd);
+				}
+				this.mappedPropertyNames.add(pd.getName());
+			}
+		}
+	}
+
+	/**
+	 * Remove the specified property from the mapped properties.
+	 * @param propertyName the property name (as used by property descriptors)
+	 */
+	protected void suppressProperty(String propertyName) {
+		if (this.mappedProperties != null) {
+			this.mappedProperties.remove(lowerCaseName(propertyName));
+			this.mappedProperties.remove(underscoreName(propertyName));
+		}
+	}
+
+	/**
+	 * Convert the given name to lower case.
+	 * By default, conversions will happen within the US locale.
+	 * @param name the original name
+	 * @return the converted name
+	 */
+	protected String lowerCaseName(String name) {
+		return name.toLowerCase(Locale.US);
+	}
+
+	/**
+	 * Convert a name in camelCase to an underscored name in lower case.
+	 * Any upper case letters are converted to lower case with a preceding underscore.
+	 * @param name the original name
+	 * @return the converted name
+	 * @see #lowerCaseName
+	 */
+	protected String underscoreName(String name) {
+		if (!StringUtils.hasLength(name)) {
+			return "";
+		}
+
+		StringBuilder result = new StringBuilder();
+		result.append(Character.toLowerCase(name.charAt(0)));
+		for (int i = 1; i < name.length(); i++) {
+			char c = name.charAt(i);
+			if (Character.isUpperCase(c)) {
+				result.append('_').append(Character.toLowerCase(c));
+			}
+			else {
+				result.append(c);
+			}
+		}
+		return result.toString();
+	}
+
+	/**
+	 * Extract the values for the current {@code Readable} :
+	 * all columns in case of a {@code Row} or all parameters in
+	 * case of an {@code OutParameters}.
+	 * Utilizes public setters and derives meta-data from the
+	 * concrete type.
+	 * @throws UnsupportedOperationException in case the concrete type
+	 * is neither {@code Row} nor {@code OutParameters}
+	 * @see RowMetadata
+	 * @see OutParametersMetadata
+	 */
+	@Override
+	public T apply(Readable readable) {
+		if (readable instanceof Row row) {
+			return mapForReadable(row, row.getMetadata().getColumnMetadatas());
+		}
+		if (readable instanceof OutParameters out) {
+			return mapForReadable(out, out.getMetadata().getParameterMetadatas());
+		}
+		throw new IllegalArgumentException("Can only map Readable Row or OutParameters, got " + readable.getClass().getName());
+	}
+
+	private  T mapForReadable(R readable, List extends ReadableMetadata> readableMetadatas) {
+		BeanWrapperImpl bw = new BeanWrapperImpl();
+		initBeanWrapper(bw);
+
+		T mappedObject = constructMappedInstance(readable, readableMetadatas, bw);
+		bw.setBeanInstance(mappedObject);
+
+		Set populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null);
+		int readableItemCount = readableMetadatas.size();
+		for(int itemIndex = 0; itemIndex < readableItemCount; itemIndex++) {
+			ReadableMetadata itemMetadata = readableMetadatas.get(itemIndex);
+			String itemName = itemMetadata.getName();
+			String property = lowerCaseName(StringUtils.delete(itemName, " "));
+			PropertyDescriptor pd = (this.mappedProperties != null ? this.mappedProperties.get(property) : null);
+			if (pd != null) {
+				Object value = getItemValue(readable, itemIndex, pd);
+				//Implementation note: the JDBC mapper can log the column mapping details each time row 0 is encountered
+				// but unfortunately this is not possible in R2DBC as row number is not provided. The BiFunction#apply
+				// cannot be stateful as it could be applied to a different row set, e.g. when resubscribing.
+				try {
+					bw.setPropertyValue(pd.getName(), value);
+				}
+				catch (TypeMismatchException ex) {
+					if (value == null && this.primitivesDefaultedForNullValue) {
+						if (logger.isDebugEnabled()) {
+							String propertyType = ClassUtils.getQualifiedName(pd.getPropertyType());
+							//here too, we miss the rowNumber information
+							logger.debug("""
+										Ignoring intercepted TypeMismatchException for item '%s' \
+										with null value when setting property '%s' of type '%s' on object: %s"
+										""".formatted(itemName, pd.getName(), propertyType, mappedObject), ex);
+						}
+					}
+					else {
+						throw ex;
+					}
+				}
+				if (populatedProperties != null) {
+					populatedProperties.add(pd.getName());
+				}
+			}
+		}
+
+		if (populatedProperties != null && !populatedProperties.equals(this.mappedPropertyNames)) {
+			throw new InvalidDataAccessApiUsageException("Given readable does not contain all items " +
+					"necessary to populate object of " + this.mappedClass + ": " + this.mappedPropertyNames);
+		}
+
+		return mappedObject;
+	}
+
+	/**
+	 * Construct an instance of the mapped class for the current {@code Readable}.
+	 * 
+	 * The default implementation simply instantiates the mapped class. Can be overridden
+	 * in subclasses.
+	 * @param readable the {@code Readable} being mapped (a {@code Row} or {@code OutParameters})
+	 * @param itemMetadatas the list of item {@code ReadableMetadata} (either
+	 * {@code ColumnMetadata} or {@code OutParameterMetadata})
+	 * @param tc a TypeConverter with this row mapper's conversion service
+	 * @return a corresponding instance of the mapped class
+	 */
+	protected T constructMappedInstance(Readable readable, List extends ReadableMetadata> itemMetadatas, TypeConverter tc) {
+		Assert.state(this.mappedClass != null, "Mapped class was not specified");
+		return BeanUtils.instantiateClass(this.mappedClass);
+	}
+
+	/**
+	 * Initialize the given BeanWrapper to be used for row mapping or outParameters
+	 * mapping.
+	 * To be called for each Readable.
+	 * 
The default implementation applies the configured {@link ConversionService},
+	 * if any. Can be overridden in subclasses.
+	 * @param bw the BeanWrapper to initialize
+	 * @see #getConversionService()
+	 * @see BeanWrapper#setConversionService
+	 */
+	protected void initBeanWrapper(BeanWrapper bw) {
+		ConversionService cs = getConversionService();
+		if (cs != null) {
+			bw.setConversionService(cs);
+		}
+	}
+
+	/**
+	 * Retrieve a R2DBC object value for the specified item index (a column or an
+	 * out-parameter).
+	 * 
The default implementation delegates to
+	 * {@link #getItemValue(Readable, int, Class)}.
+	 * @param readable is the {@code Row} or {@code OutParameters} holding the data
+	 * @param itemIndex is the column index or out-parameter index
+	 * @param pd the bean property that each result object is expected to match
+	 * @return the Object value
+	 * @see #getItemValue(Readable, int, Class)
+	 */
+	@Nullable
+	protected Object getItemValue(Readable readable, int itemIndex, PropertyDescriptor pd) {
+		return getItemValue(readable, itemIndex, pd.getPropertyType());
+	}
+
+	/**
+	 * Retrieve a R2DBC object value for the specified item index (a column or
+	 * an out-parameter).
+	 * 
The default implementation calls {@link Readable#get(int, Class)} then
+	 * falls back to {@link Readable#get(int)} in case of an exception.
+	 * Subclasses may override this to check specific value types upfront,
+	 * or to post-process values return from {@code get}.
+	 * @param readable is the {@code Row} or {@code OutParameters} holding the data
+	 * @param itemIndex is the column index or out-parameter index
+	 * @param paramType the target parameter type
+	 * @return the Object value
+	 * @see Readable#get(int, Class)
+	 * @see Readable#get(int)
+	 */
+	@Nullable
+	protected Object getItemValue(Readable readable, int itemIndex, Class> paramType) {
+		try {
+			return readable.get(itemIndex, paramType);
+		}
+		catch (Throwable ex) {
+			return readable.get(itemIndex);
+		}
+	}
+
+
+	/**
+	 * Static factory method to create a new {@code BeanPropertyRowMapper}.
+	 * @param mappedClass the class that each row should be mapped to
+	 * @see #newInstance(Class, ConversionService)
+	 */
+	public static  BeanPropertyRowMapper newInstance(Class mappedClass) {
+		return new BeanPropertyRowMapper<>(mappedClass);
+	}
+
+	/**
+	 * Static factory method to create a new {@code BeanPropertyRowMapper}.
+	 * @param mappedClass the class that each row should be mapped to
+	 * @param conversionService the {@link ConversionService} for binding
+	 * R2DBC values to bean properties, or {@code null} for none
+	 * @see #newInstance(Class)
+	 * @see #setConversionService
+	 */
+	public static  BeanPropertyRowMapper newInstance(
+			Class mappedClass, @Nullable ConversionService conversionService) {
+
+		BeanPropertyRowMapper rowMapper = newInstance(mappedClass);
+		rowMapper.setConversionService(conversionService);
+		return rowMapper;
+	}
+
+}
diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java
new file mode 100644
index 000000000000..87a67e73873c
--- /dev/null
+++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/DataClassRowMapper.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2002-2023 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.springframework.r2dbc.core;
+
+import java.lang.reflect.Constructor;
+import java.util.List;
+
+import io.r2dbc.spi.Readable;
+import io.r2dbc.spi.ReadableMetadata;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.TypeConverter;
+import org.springframework.core.MethodParameter;
+import org.springframework.core.convert.ConversionService;
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.dao.DataRetrievalFailureException;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * Mapping {@code Function} implementation that converts an R2DBC {@code Readable}
+ * (a {@code Row} or {@code OutParameters}) into a new instance of the specified mapped
+ * target class. The mapped target class must be a top-level class or {@code static}
+ * nested class, and it may expose either a data class constructor with named
+ * parameters corresponding to column names or classic bean property setter methods
+ * with property names corresponding to column names (or even a combination of both).
+ *
+ * 
+ * The term "data class" applies to Java records, Kotlin data classes,
+ * and any class which has a constructor with named parameters that are intended to be
+ * mapped to corresponding column names.
+ *
+ * 
+ * When combining a data class constructor with setter methods, any property mapped
+ * successfully via a constructor argument will not be mapped additionally via a
+ * corresponding setter method. This means that constructor arguments take precedence over
+ * property setter methods.
+ *
+ * 
+ * Note that this class extends {@link BeanPropertyRowMapper} and can therefore serve as a
+ * common choice for any mapped target class, flexibly adapting to constructor style
+ * versus setter methods in the mapped class.
+ *
+ * 
+ * Please note that this class is designed to provide convenience rather than high
+ * performance. For best performance, consider using a custom readable mapping
+ * {@code Function} implementation.
+ *
+ * @author Simon Baslé
+ * @since 6.1
+ * @param  the result type
+ */
+// Note: this class is adapted from the DataClassRowMapper in spring-jdbc
+public class DataClassRowMapper extends BeanPropertyRowMapper {
+
+	@Nullable
+	private Constructor mappedConstructor;
+
+	@Nullable
+	private String[] constructorParameterNames;
+
+	@Nullable
+	private TypeDescriptor[] constructorParameterTypes;
+
+
+	/**
+	 * Create a new {@code DataClassRowMapper}.
+	 * @param mappedClass the class that each row should be mapped to
+	 */
+	public DataClassRowMapper(Class mappedClass) {
+		super(mappedClass);
+	}
+
+
+	@Override
+	protected void initialize(Class mappedClass) {
+		super.initialize(mappedClass);
+
+		this.mappedConstructor = BeanUtils.getResolvableConstructor(mappedClass);
+		int paramCount = this.mappedConstructor.getParameterCount();
+		if (paramCount > 0) {
+			this.constructorParameterNames = BeanUtils.getParameterNames(this.mappedConstructor);
+			for (String name : this.constructorParameterNames) {
+				suppressProperty(name);
+			}
+			this.constructorParameterTypes = new TypeDescriptor[paramCount];
+			for (int i = 0; i < paramCount; i++) {
+				this.constructorParameterTypes[i] = new TypeDescriptor(new MethodParameter(this.mappedConstructor, i));
+			}
+		}
+	}
+
+	@Override
+	protected T constructMappedInstance(Readable readable, List extends ReadableMetadata> itemMetadatas, TypeConverter tc) {
+		Assert.state(this.mappedConstructor != null, "Mapped constructor was not initialized");
+
+		Object[] args;
+		if (this.constructorParameterNames != null && this.constructorParameterTypes != null) {
+			args = new Object[this.constructorParameterNames.length];
+			for (int i = 0; i < args.length; i++) {
+				String name = this.constructorParameterNames[i];
+				int index = findIndex(readable, itemMetadatas, lowerCaseName(name));
+				if (index == -1) {
+					index = findIndex(readable, itemMetadatas, underscoreName(name));
+				}
+				if (index == -1) {
+					throw new DataRetrievalFailureException("Unable to map constructor parameter '" + name + "' to a column or out-parameter");
+				}
+				TypeDescriptor td = this.constructorParameterTypes[i];
+				Object value = getItemValue(readable, index, td.getType());
+				args[i] = tc.convertIfNecessary(value, td.getType(), td);
+			}
+		}
+		else {
+			args = new Object[0];
+		}
+
+		return BeanUtils.instantiateClass(this.mappedConstructor, args);
+	}
+
+	private int findIndex(Readable readable, List extends ReadableMetadata> itemMetadatas, String name) {
+		int index = 0;
+		for (ReadableMetadata itemMetadata : itemMetadatas) {
+			//we use equalsIgnoreCase, similarly to RowMetadata#contains(String)
+			if (itemMetadata.getName().equalsIgnoreCase(name)) {
+				return index;
+			}
+			index++;
+		}
+		return -1;
+	}
+
+
+	/**
+	 * Static factory method to create a new {@code DataClassRowMapper}.
+	 * @param mappedClass the class that each row should be mapped to
+	 * @see #newInstance(Class, ConversionService)
+	 */
+	public static  DataClassRowMapper newInstance(Class mappedClass) {
+		return new DataClassRowMapper<>(mappedClass);
+	}
+
+	/**
+	 * Static factory method to create a new {@code DataClassRowMapper}.
+	 * @param mappedClass the class that each row should be mapped to
+	 * @param conversionService the {@link ConversionService} for binding
+	 * R2DBC values to bean properties, or {@code null} for none
+	 * @see #newInstance(Class)
+	 * @see #setConversionService
+	 */
+	public static  DataClassRowMapper newInstance(
+			Class mappedClass, @Nullable ConversionService conversionService) {
+
+		DataClassRowMapper rowMapper = newInstance(mappedClass);
+		rowMapper.setConversionService(conversionService);
+		return rowMapper;
+	}
+
+}
diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java
new file mode 100644
index 000000000000..67a076f7f587
--- /dev/null
+++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/BeanPropertyRowMapperTests.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2002-2023 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.springframework.r2dbc.core;
+
+import io.r2dbc.spi.Readable;
+import io.r2dbc.spi.test.MockColumnMetadata;
+import io.r2dbc.spi.test.MockOutParameters;
+import io.r2dbc.spi.test.MockRow;
+import io.r2dbc.spi.test.MockRowMetadata;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.Mockito;
+
+import org.springframework.beans.TypeMismatchException;
+import org.springframework.dao.InvalidDataAccessApiUsageException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+class BeanPropertyRowMapperTests {
+
+	@Test
+	void mappingUnknownReadableRejected() {
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class);
+		assertThatIllegalArgumentException().isThrownBy(() -> mapper.apply(Mockito.mock(Readable.class)))
+				.withMessageStartingWith("Can only map Readable Row or OutParameters, got io.r2dbc.spi.Readable$MockitoMock$");
+	}
+
+	@Test
+	void mappingOutParametersAccepted() {
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class);
+		assertThatNoException().isThrownBy(() -> mapper.apply(MockOutParameters.empty()));
+	}
+
+	@Test
+	void mappingRowSimpleObject() {
+		MockRow mockRow = SIMPLE_PERSON_ROW;
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class);
+
+		Person result = mapper.apply(mockRow);
+
+		assertThat(result.firstName).as("firstName").isEqualTo("John");
+		assertThat(result.lastName).as("lastName").isEqualTo("Doe");
+		assertThat(result.age).as("age").isEqualTo(30);
+	}
+
+	@Test
+	void mappingRowMissingAttributeAccepted() {
+		MockRow mockRow = SIMPLE_PERSON_ROW;
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class);
+
+		ExtendedPerson result = mapper.apply(mockRow);
+
+		assertThat(result.firstName).as("firstName").isEqualTo("John");
+		assertThat(result.lastName).as("lastName").isEqualTo("Doe");
+		assertThat(result.age).as("age").isEqualTo(30);
+		assertThat(result.address).as("address").isNull();
+	}
+
+	@Test
+	void mappingRowWithDifferentName() {
+		MockRow mockRow = EMAIL_PERSON_ROW;
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(EmailPerson.class);
+
+		EmailPerson result = mapper.apply(mockRow);
+
+		assertThat(result.firstName).as("firstName").isEqualTo("John");
+		assertThat(result.lastName).as("lastName").isEqualTo("Doe");
+		assertThat(result.age).as("age").isEqualTo(30);
+		assertThat(result.email).as("email").isEqualTo("mail@example.org");
+	}
+
+	@Test
+	void mappingRowMissingAttributeRejected() {
+		MockRow mockRow = SIMPLE_PERSON_ROW;
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(ExtendedPerson.class, true);
+
+		assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
+				.isThrownBy(() -> mapper.apply(mockRow))
+				.withMessage("Given readable does not contain all items necessary to populate object of class org.springframework."
+						+ "r2dbc.core.BeanPropertyRowMapperTests$ExtendedPerson: [firstName, lastName, address, age]");
+	}
+
+	//TODO cannot trigger a mapping of a read-only property, as mappedProperties don't include properties without a setter.
+
+	@Test
+	void rowTypeAndMappingTypeMisaligned() {
+		MockRow mockRow = EXTENDED_PERSON_ROW;
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(TypeMismatchExtendedPerson.class);
+
+		assertThatExceptionOfType(TypeMismatchException.class)
+				.isThrownBy(() -> mapper.apply(mockRow))
+				.withMessage("Failed to convert property value of type 'java.lang.String' to required type "
+						+ "'java.lang.String' for property 'address'; simulating type mismatch for address");
+	}
+
+	@Test
+	void usePrimitiveDefaultWithNullValueFromRow() {
+		MockRow mockRow = MockRow.builder()
+				.metadata(MockRowMetadata.builder()
+						.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name("lastName").javaType(String.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
+						.build())
+				.identified(0, String.class, "John")
+				.identified(1, String.class, "Doe")
+				.identified(2, int.class, null)
+				.identified(3, String.class, "123 Sesame Street")
+				.build();
+		final BeanPropertyRowMapper mapper = new BeanPropertyRowMapper<>(Person.class);
+		mapper.setPrimitivesDefaultedForNullValue(true);
+
+		Person result = mapper.apply(mockRow);
+
+		assertThat(result.getAge()).isZero();
+	}
+
+	@ParameterizedTest
+	@CsvSource({
+			"age, age",
+			"lastName, last_name",
+			"Name, name",
+			"FirstName, first_name",
+			"EMail, e_mail",
+			"URL, u_r_l", // likely undesirable, but that's the status quo
+	})
+	void underscoreName(String input, String expected) {
+		BeanPropertyRowMapper> mapper = new BeanPropertyRowMapper<>(Object.class);
+		assertThat(mapper.underscoreName(input)).isEqualTo(expected);
+	}
+
+
+
+	private static class Person {
+
+		String firstName;
+		String lastName;
+		int age;
+
+		public String getFirstName() {
+			return this.firstName;
+		}
+
+		public void setFirstName(String firstName) {
+			this.firstName = firstName;
+		}
+
+		public String getLastName() {
+			return this.lastName;
+		}
+
+		public void setLastName(String lastName) {
+			this.lastName = lastName;
+		}
+
+		public int getAge() {
+			return this.age;
+		}
+
+		public void setAge(int age) {
+			this.age = age;
+		}
+	}
+
+	private static class ExtendedPerson extends Person {
+
+		String address;
+
+		public String getAddress() {
+			return this.address;
+		}
+
+		public void setAddress(String address) {
+			this.address = address;
+		}
+	}
+
+	private static class TypeMismatchExtendedPerson extends ExtendedPerson {
+
+		@Override
+		public void setAddress(String address) {
+			throw new ClassCastException("simulating type mismatch for address");
+		}
+	}
+
+	private static class EmailPerson extends Person {
+
+		String email;
+
+		public String getEmail() {
+			return this.email;
+		}
+
+		public void setEmail(String email) {
+			this.email = email;
+		}
+	}
+
+	private static final MockRow SIMPLE_PERSON_ROW = MockRow.builder()
+			.metadata(MockRowMetadata.builder()
+					.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
+					.columnMetadata(MockColumnMetadata.builder().name("lastName").javaType(String.class).build())
+					.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
+					.build())
+			.identified(0, String.class, "John")
+			.identified(1, String.class, "Doe")
+			.identified(2, int.class, 30)
+			.build();
+
+	private static final MockRow EXTENDED_PERSON_ROW = MockRow.builder()
+			.metadata(MockRowMetadata.builder()
+					.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
+					.columnMetadata(MockColumnMetadata.builder().name("lastName").javaType(String.class).build())
+					.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
+					.columnMetadata(MockColumnMetadata.builder().name("address").javaType(String.class).build())
+					.build())
+			.identified(0, String.class, "John")
+			.identified(1, String.class, "Doe")
+			.identified(2, int.class, 30)
+			.identified(3, String.class, "123 Sesame Street")
+			.build();
+
+	private static final MockRow EMAIL_PERSON_ROW = buildRowWithExtraColum("EMail", String.class,
+			String.class, "mail@example.org");
+
+	private static final MockRow buildRowWithExtraColum(String extraColumnName, Class> extraColumnClass, Class> identifiedClass, Object value) {
+		return MockRow.builder()
+				.metadata(MockRowMetadata.builder()
+						.columnMetadata(MockColumnMetadata.builder().name("firstName").javaType(String.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name("last_name").javaType(String.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name("age").javaType(Integer.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name(extraColumnName).javaType(extraColumnClass).build())
+						.build())
+				.identified(0, String.class, "John")
+				.identified(1, String.class, "Doe")
+				.identified(2, int.class, 30)
+				.identified(3, identifiedClass, value)
+				.build();
+	}
+
+}
diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java
new file mode 100644
index 000000000000..ecc9cb175f7b
--- /dev/null
+++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/DataClassRowMapperTests.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2002-2023 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.springframework.r2dbc.core;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+import io.r2dbc.spi.test.MockColumnMetadata;
+import io.r2dbc.spi.test.MockRow;
+import io.r2dbc.spi.test.MockRowMetadata;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DataClassRowMapperTests {
+
+	@Test
+	void staticQueryWithDataClass() {
+		MockRow mockRow = MOCK_ROW; // uses name, age, birth_date
+		final DataClassRowMapper mapper = new DataClassRowMapper<>(ConstructorPerson.class);
+
+		ConstructorPerson person = mapper.apply(mockRow);
+
+		assertThat(person.name).as("name").isEqualTo("Bubba");
+		assertThat(person.age).as("age").isEqualTo(22L);
+		assertThat(person.birth_date).as("birth_date").isNotNull();
+	}
+
+	@Test
+	void staticQueryWithDataClassAndGenerics() {
+		MockRow mockRow = buildMockRow("birth_date", true); // uses name, age, birth_date, balance (as list)
+		//TODO validate actual R2DBC Row implementations would return something for balance if asking a List
+		final DataClassRowMapper mapper = new DataClassRowMapper<>(ConstructorPersonWithGenerics.class);
+		ConstructorPersonWithGenerics person = mapper.apply(mockRow);
+
+		assertThat(person.name()).isEqualTo("Bubba");
+		assertThat(person.age()).isEqualTo(22L);
+		assertThat(person.birth_date()).usingComparator(Date::compareTo).isEqualTo(new Date(1221222L));
+		assertThat(person.balance()).containsExactly(new BigDecimal("1234.56"));
+	}
+
+	@Test
+	void staticQueryWithDataRecord() {
+		MockRow mockRow = MOCK_ROW; // uses name, age, birth_date, balance
+		final DataClassRowMapper mapper = new DataClassRowMapper<>(RecordPerson.class);
+		RecordPerson person = mapper.apply(mockRow);
+
+		assertThat(person.name()).isEqualTo("Bubba");
+		assertThat(person.age()).isEqualTo(22L);
+		assertThat(person.birth_date()).usingComparator(Date::compareTo).isEqualTo(new Date(1221222L));
+		assertThat(person.balance()).isEqualTo(new BigDecimal("1234.56"));
+	}
+
+	@Test
+	void staticQueryWithDataClassAndSetters() {
+		MockRow mockRow = buildMockRow("birthdate", false); // uses name, age, birthdate (no underscore), balance
+		final DataClassRowMapper mapper = new DataClassRowMapper<>(ConstructorPersonWithSetters.class);
+		ConstructorPersonWithSetters person = mapper.apply(mockRow);
+
+		assertThat(person.name()).isEqualTo("BUBBA");
+		assertThat(person.age()).isEqualTo(22L);
+		assertThat(person.birthDate()).usingComparator(Date::compareTo).isEqualTo(new Date(1221222L));
+		assertThat(person.balance()).isEqualTo(new BigDecimal("1234.56"));
+	}
+
+	static class ConstructorPerson {
+
+		final String name;
+
+		final long age;
+
+		final Date birth_date;
+
+		public ConstructorPerson(String name, long age, Date birth_date) {
+			this.name = name;
+			this.age = age;
+			this.birth_date = birth_date;
+		}
+
+		public String name() {
+			return this.name;
+		}
+
+		public long age() {
+			return this.age;
+		}
+
+		public Date birth_date() {
+			return this.birth_date;
+		}
+	}
+
+	static class ConstructorPersonWithGenerics extends ConstructorPerson {
+
+		private final List balance;
+
+		public ConstructorPersonWithGenerics(String name, long age, Date birth_date, List balance) {
+			super(name, age, birth_date);
+			this.balance = balance;
+		}
+
+		public List balance() {
+			return this.balance;
+		}
+	}
+
+	static class ConstructorPersonWithSetters {
+
+		private String name;
+
+		private long age;
+
+		private Date birthDate;
+
+		private BigDecimal balance;
+
+
+		public ConstructorPersonWithSetters(String name, long age, Date birthDate, BigDecimal balance) {
+			this.name = name.toUpperCase();
+			this.age = age;
+			this.birthDate = birthDate;
+			this.balance = balance;
+		}
+
+
+		public void setName(String name) {
+			this.name = name;
+		}
+
+		public void setAge(long age) {
+			this.age = age;
+		}
+
+		public void setBirthDate(Date birthDate) {
+			this.birthDate = birthDate;
+		}
+
+		public void setBalance(BigDecimal balance) {
+			this.balance = balance;
+		}
+
+		public String name() {
+			return this.name;
+		}
+
+		public long age() {
+			return this.age;
+		}
+
+		public Date birthDate() {
+			return this.birthDate;
+		}
+
+		public BigDecimal balance() {
+			return this.balance;
+		}
+	}
+
+	static record RecordPerson(String name, long age, Date birth_date, BigDecimal balance) {
+	}
+
+	static MockRow MOCK_ROW = buildMockRow("birth_date", false);
+
+	private static MockRow buildMockRow(String birthDateColumnName, boolean balanceObjectIdentifier) {
+		final MockRow.Builder builder = MockRow.builder();
+		builder.metadata(MockRowMetadata.builder()
+						.columnMetadata(MockColumnMetadata.builder().name("name").javaType(String.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name("age").javaType(long.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name(birthDateColumnName).javaType(Date.class).build())
+						.columnMetadata(MockColumnMetadata.builder().name("balance").javaType(BigDecimal.class).build())
+						.build())
+				.identified(0, String.class, "Bubba")
+				.identified(1, long.class, 22)
+				.identified(2, Date.class, new Date(1221222L))
+				.identified(3, BigDecimal.class, new BigDecimal("1234.56"));
+		if (balanceObjectIdentifier) {
+			builder.identified(3, Object.class, new BigDecimal("1234.56"));
+		}
+		return builder.build();
+	}
+
+}